Compare commits
No commits in common. "main" and "prototype" have entirely different histories.
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,3 +1,2 @@
|
||||
build/
|
||||
node_modules/
|
||||
*.temp
|
||||
music-metadata/
|
||||
node_modules/
|
Binary file not shown.
Before Width: | Height: | Size: 696 B |
Binary file not shown.
Before Width: | Height: | Size: 802 B |
404
assets/main.css
404
assets/main.css
@ -1,404 +0,0 @@
|
||||
:root {
|
||||
/* --iiv: #000;
|
||||
--iv: #444;
|
||||
--v: #0bb;
|
||||
--iv: #aaa;
|
||||
--iiv: #fff;
|
||||
--a: #f33; */
|
||||
|
||||
--iiv: #ddd;
|
||||
--iv: #aaa;
|
||||
--v: #375;
|
||||
--vi: #555;
|
||||
--vii: #222;
|
||||
--a: #f40;
|
||||
|
||||
--chevron-right: url("data:image/svg+xml,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 5v2h2V5H8zm4 4V7h-2v2h2zm2 2V9h-2v2h2zm0 2h2v-2h-2v2zm-2 2v-2h2v2h-2zm0 0h-2v2h2v-2zm-4 4v-2h2v2H8z' fill='currentColor'/%3E%3C/svg%3E")
|
||||
}
|
||||
|
||||
body:not(.player) .player { display: none; }
|
||||
body:not(.browser) .browser { display: none; }
|
||||
body:not(.queue) .queue { display: none; }
|
||||
body:not(.playing) .playing { display: none; }
|
||||
body:not(.paused) .paused { display: none; }
|
||||
|
||||
button, a {
|
||||
all: unset;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
button:hover, a:hover, .focus {
|
||||
color: var(--a)
|
||||
}
|
||||
button:active, a:active {
|
||||
color: var(--v)
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--iiv);
|
||||
color: var(--vii);
|
||||
|
||||
font-family: sans-serif;
|
||||
font-size: 16px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
section > header {
|
||||
height: 20px;
|
||||
padding: 0 5px 5px 5px;
|
||||
|
||||
font-weight: bold;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
section > header h4 {
|
||||
display: block;
|
||||
/* color: var(--v); */
|
||||
margin: 0;
|
||||
line-height: 100%;
|
||||
}
|
||||
/* section > header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 30px;
|
||||
line-height: 26px;
|
||||
background: linear-gradient(180deg, var(--iiv), 80% transparent);
|
||||
} */
|
||||
|
||||
menu li:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
menu {
|
||||
margin: 0;
|
||||
padding: 0 5px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
menu li {
|
||||
list-style-type: none;
|
||||
height: 28px;
|
||||
margin: 8px 0;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
menu a::after {
|
||||
content: " ";
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: currentColor;
|
||||
mask: var(--chevron-right);
|
||||
mask-mode: alpha;
|
||||
margin-left: auto;
|
||||
position: sticky;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
menu header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
menu a, menu li {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
menu code {
|
||||
width: 16px;
|
||||
margin-right: 5px;
|
||||
color: var(--v)
|
||||
}
|
||||
|
||||
menu section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
menu b, menu code {
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
menu b {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
menu cite {
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
menu section, menu b, menu cite {
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
menu code {
|
||||
margin-right: 5px;
|
||||
color: var(--v)
|
||||
}
|
||||
|
||||
menu:empty::after {
|
||||
|
||||
position: absolute;
|
||||
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
content: "\2014 Empty \2014";
|
||||
}
|
||||
|
||||
menu:empty::after, #loading {
|
||||
color: var(--vi);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav {
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
|
||||
background: var(--iiv);
|
||||
color: var(--vii);
|
||||
|
||||
display: flex;
|
||||
padding: 0 5px;
|
||||
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
box-sizing: border-box;
|
||||
line-height: 26px;
|
||||
margin-top: auto;
|
||||
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
letter-spacing: -0.5px;
|
||||
box-sizing: border-box;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.left {
|
||||
text-align: left;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-transform: uppercase;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
/* nav button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
nav, nav button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
nav svg {
|
||||
stroke-width: 0;
|
||||
stroke-linecap: square;
|
||||
stroke-linejoin: square;
|
||||
stroke: currentcolor;
|
||||
fill: currentcolor;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
} */
|
||||
|
||||
section {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
section.player {
|
||||
padding: 0 5px;
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
section.player img {
|
||||
width: 230px;
|
||||
height: 230px;
|
||||
}
|
||||
|
||||
#track-title {
|
||||
font-size: 20px;
|
||||
margin-top: 2px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
#track-artist {
|
||||
font-size: 14px;
|
||||
margin: 0 50px 5px 50px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#track-title, #track-artist {
|
||||
text-wrap: nowrap;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.marquee {
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.marquee span {
|
||||
display: inline-block;
|
||||
animation: marquee 60s linear infinite;
|
||||
padding-right: 4ch;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% { transform: translate(0, 0) }
|
||||
25% { transform: translate(-100%, 0) }
|
||||
100% { transform: translate(-100%, 0) }
|
||||
}
|
||||
|
||||
#playpause {
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentcolor;
|
||||
}
|
||||
#timestamp, #track-length {
|
||||
position: absolute;
|
||||
padding: 5px;
|
||||
font-size: 13px;
|
||||
bottom: 0;
|
||||
}
|
||||
#timestamp {
|
||||
color: var(--a);
|
||||
left: 0;
|
||||
}
|
||||
#track-length {
|
||||
color: var(--vi);
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
color: var(--vii)
|
||||
}
|
||||
|
||||
.progress path {
|
||||
stroke-width: 2px;
|
||||
stroke-dasharray: 100;
|
||||
stroke-dashoffset: 50;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: 100%;
|
||||
block-size: 3px;
|
||||
appearance: none;
|
||||
}
|
||||
progress::-webkit-progress-bar {
|
||||
background-color: var(--iv);
|
||||
}
|
||||
progress::-webkit-progress-value {
|
||||
background-color: var(--vii);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
background: none;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--vi);
|
||||
}
|
||||
|
||||
body > svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#tab-indicator {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0 5px 3px 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#tab-indicator line {
|
||||
stroke: var(--v);
|
||||
transition: stroke-dasharray 0.1s ease-in;
|
||||
}
|
||||
|
||||
/* body > header {
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#tabs {
|
||||
margin: 0 5px;
|
||||
scrollbar-width: none;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 25px;
|
||||
overflow-x: scroll;
|
||||
|
||||
color: var(--vi);
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#tabs .selected {
|
||||
color: var(--vii);
|
||||
min-width: 60%;
|
||||
} */
|
||||
|
||||
.progress {
|
||||
background: var(--iv);
|
||||
}
|
||||
|
||||
#playback-progress {
|
||||
transition: stroke-dasharray 0.1s;
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 565 B |
@ -1,49 +0,0 @@
|
||||
import Store from 'store'
|
||||
|
||||
const noWorkingDirWarning = "Please open a directory to allow file saving."
|
||||
|
||||
export let root
|
||||
export let store
|
||||
|
||||
export async function Start() {
|
||||
store = await Store.Open('device', 'handles')
|
||||
root = await store.Get('directory')
|
||||
}
|
||||
|
||||
export async function cd() {
|
||||
root = await window.showDirectoryPicker()
|
||||
await store.Set('directory', root)
|
||||
}
|
||||
|
||||
export async function Entries(handle = root) {
|
||||
let pool = new Map()
|
||||
|
||||
for await (let [ , e ] of handle.entries()) {
|
||||
pool.set(e.name, e)
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
export async function Create(sPath) {
|
||||
return root.getFileHandle(sPath, { create: true })
|
||||
}
|
||||
|
||||
export async function Open(hFile) {
|
||||
if(!root) {
|
||||
return new Blob([ noWorkingDirWarning ], { type: "text/plain" })
|
||||
}
|
||||
|
||||
return hFile.getFile()
|
||||
}
|
||||
|
||||
export async function Write(hFile, sContent) {
|
||||
if(!root) {
|
||||
throw new Error("Unable to save file: no working directory")
|
||||
}
|
||||
|
||||
let s = await hFile.createWritable()
|
||||
.catch(console.error)
|
||||
await s.write(sContent)
|
||||
await s.close()
|
||||
}
|
@ -1,95 +0,0 @@
|
||||
export const sdcard = navigator.getDeviceStorage('sdcard')
|
||||
export let root
|
||||
|
||||
export function promisify(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = (event) => {
|
||||
resolve(request.result)
|
||||
}
|
||||
request.onerror = (event) => {
|
||||
reject(request.error)
|
||||
console.error(request.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function joinPath(left, right) {
|
||||
return (left + right).replaceAll(/\/{2,}/g, '/')
|
||||
}
|
||||
|
||||
function handle(sPath, sName) {
|
||||
return {
|
||||
name: sName,
|
||||
path: sPath,
|
||||
kind: 'directory'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHandle(handle) {
|
||||
let cs = handle.path.split('/')
|
||||
let f = root
|
||||
|
||||
for(let c of cs) {
|
||||
f = f.entries[c]
|
||||
if(f == undefined) {
|
||||
throw new Error('Invalid path:', handle.path)
|
||||
}
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
export function Start() {
|
||||
|
||||
}
|
||||
|
||||
export const createIndex = () => {
|
||||
return new Promise(filesystemIndexHandler)
|
||||
}
|
||||
|
||||
const filesystemIndexHandler = (resolve, reject) => {
|
||||
let cursor = sdcard.enumerate();
|
||||
root = {}
|
||||
|
||||
cursor.onerror = function() {
|
||||
reject(cursor.error)
|
||||
}
|
||||
cursor.onsuccess = function() {
|
||||
if(!this.result) {
|
||||
return resolve(root)
|
||||
}
|
||||
let cs = this.result.name.split('/').slice(1)
|
||||
let f = root
|
||||
let c
|
||||
for(let i = 0; i < cs.length; i++) {
|
||||
c = cs[i]
|
||||
if(!f.entries) {
|
||||
f.kind = 'directory'
|
||||
f.entries = {}
|
||||
}
|
||||
f = f.entries[c] ??= handle('/' + cs.slice(0, i + 1).join('/'), c)
|
||||
}
|
||||
f.kind = 'file'
|
||||
// f.file = this.result
|
||||
|
||||
this.continue()
|
||||
}
|
||||
}
|
||||
|
||||
export async function Entries(handle) {
|
||||
if(!root)
|
||||
await createIndex()
|
||||
let pool = new Map()
|
||||
let t = handle == null ? root : handle
|
||||
for(let n in t.entries ?? {}) {
|
||||
let h = t.entries[n]
|
||||
pool.set(h.name, h)
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
export function Open(hFile) {
|
||||
// return hFile.file
|
||||
return promisify(sdcard.get(hFile.path))
|
||||
}
|
45
device/nw.js
45
device/nw.js
@ -1,45 +0,0 @@
|
||||
|
||||
const FS = require('fs/promises')
|
||||
const Path = require('path')
|
||||
|
||||
export const sdcard = navigator.getDeviceStorage("sdcard")
|
||||
export const music = navigator.getDeviceStorage("music")
|
||||
|
||||
function handle(sPath) {
|
||||
return {
|
||||
name: sPath,
|
||||
kind: sPath,
|
||||
path: sPath
|
||||
}
|
||||
}
|
||||
|
||||
export async function Init() {
|
||||
// await loadTheme()
|
||||
|
||||
}
|
||||
|
||||
export async function Entries() {
|
||||
let pool = new Map()
|
||||
|
||||
for(let d of await FS.readdir(window.cwd, { withFileTypes: true })) {
|
||||
if(d.isFile()) {
|
||||
let h = handle(d.name)
|
||||
pool.set(d.name, h)
|
||||
}
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
export async function Create(sPath) {
|
||||
return handle(sPath)
|
||||
}
|
||||
|
||||
export async function Open(hFile) {
|
||||
let b = await FS.readFile(hFile.path)
|
||||
return new File([ b ], hFile.name)
|
||||
}
|
||||
|
||||
export async function Write(hFile, sContent) {
|
||||
return await FS.writeFile(hFile.path, sContent, { encoding: 'utf-8' })
|
||||
}
|
135
index.html
135
index.html
@ -1,102 +1,71 @@
|
||||
<head>
|
||||
|
||||
<title>hiss</title>
|
||||
<!-- start injection -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"device": "./device/browser.js",
|
||||
"store": "./lib/store.js"
|
||||
}
|
||||
}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="node_modules/jsmediatags/dist/jsmediatags.min.js"></script>
|
||||
<script type="module">
|
||||
import * as IDBStore from './node_modules/idb-keyval/dist/index.js'
|
||||
|
||||
window.IDBStore = IDBStore
|
||||
</script>
|
||||
<script src="./node_modules/jsmediatags/dist/jsmediatags.min.js"></script>
|
||||
<script type="module" src="src/main.js"></script>
|
||||
<!-- end injection -->
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
max-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
section:not(.open) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
menu {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
menu li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
list-style-type: none;
|
||||
height: 28px;
|
||||
margin: 8px 0;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
menu li img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
menu li aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
menu .focus {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
<script src="index.js"></script>
|
||||
<link rel="stylesheet" href="style.css"></link>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<body onload="main(this)">
|
||||
|
||||
<main data-action-set="main">
|
||||
<main>
|
||||
|
||||
<section id="browser">
|
||||
<section class="controls">
|
||||
|
||||
<header>Browser</header>
|
||||
<menu data-action-set="browser" id="browser-menu"></menu>
|
||||
<header>
|
||||
|
||||
<h3 id="track-title">Bocce</h3>
|
||||
<p id="track-artist">Patricia Taxxon</p>
|
||||
|
||||
</header>
|
||||
<nav>
|
||||
|
||||
<button id="prev">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" baseProfile="full" version="1.1" viewbox="0 0 120 120" preserveAspectRatio="xMidYMid meet">
|
||||
<path d="M15,60 L15,60 L60,105 L60,15 L15,60 M60,60 L60,60 L105,15 L105,105 L60,60 "/>
|
||||
</svg>
|
||||
|
||||
</button>
|
||||
<button id="playpause">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" baseProfile="full" version="1.1" viewbox="0 0 105 120" preserveAspectRatio="xMidYMid meet">
|
||||
<path id="pause-icon" d="M15,15 L15,15 L15,105 L45,105 L45,15 L15,15 M60,15 L60,15 L60,105 L90,105 L90,15 L60,15 "/>
|
||||
<path id="play-icon" d="M30,15 L30,15 L30,105 L90,60 L30,15 "/>
|
||||
</svg>
|
||||
|
||||
</button>
|
||||
<button id="next">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" baseProfile="full" version="1.1" viewbox="0 0 120 120" preserveAspectRatio="xMidYMid meet">
|
||||
<path d="M60,60 L60,60 L15,105 L15,15 L60,60 M105,60 L105,60 L60,15 L60,105 L105,60 " />
|
||||
</svg>
|
||||
|
||||
</button>
|
||||
|
||||
</nav>
|
||||
<code id="timestamp">00:00</code>
|
||||
<code id="track-length">00:00</code>
|
||||
|
||||
</section>
|
||||
<section id="queue">
|
||||
<progress id="playback-progress" value="0" max="100"></progress>
|
||||
<section class="content">
|
||||
|
||||
<header>Queue</header>
|
||||
<menu data-action-set="menu" id="queue-menu"></menu>
|
||||
<ul id="list"></ul>
|
||||
|
||||
</section>
|
||||
<section id="player">
|
||||
<footer>
|
||||
|
||||
<img id="track-cover">
|
||||
<cite id="track-title"></cite>
|
||||
<cite id="track-artist"></cite>
|
||||
<div id="left-action">Back</div>
|
||||
|
||||
<code id="track-current-time">--:--</code>
|
||||
<code id="track-duration">--:--</code>
|
||||
<div id="right-action" onclick="onAddFiles()">Add Files</div>
|
||||
|
||||
</section>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
|
||||
|
2
index.js
2
index.js
@ -231,6 +231,8 @@ const tagEntry = async (entry) => {
|
||||
tags = /((?<track>\d+)\s+)?((?<artist>.+)-\s*)?\s*(?<title>.+)\s*\.\w+/
|
||||
.exec(entry.name)
|
||||
.groups
|
||||
|
||||
console.log(tags)
|
||||
}
|
||||
|
||||
entry.tags = {
|
||||
|
47
lib/store.js
47
lib/store.js
@ -1,47 +0,0 @@
|
||||
export default function Store(objectStoreName) {
|
||||
const transact = () => {
|
||||
return this.db
|
||||
.transaction(objectStoreName, "readwrite")
|
||||
.objectStore(objectStoreName)
|
||||
}
|
||||
|
||||
this.Get = (key) => {
|
||||
return promisify(
|
||||
transact().get(key)
|
||||
)
|
||||
}
|
||||
|
||||
this.Set = (key, value) => {
|
||||
return promisify(
|
||||
transact().put(value, key)
|
||||
)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
Store.Open = async (dbName, objectStoreName) => {
|
||||
let store = new Store(objectStoreName)
|
||||
const request = indexedDB.open(dbName, 2)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result
|
||||
const objectStore = db.createObjectStore(objectStoreName)
|
||||
|
||||
objectStore.createIndex('value', 'value', { unique: false })
|
||||
}
|
||||
|
||||
store.db = await promisify(request)
|
||||
return store
|
||||
}
|
||||
|
||||
export function promisify(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = (event) => {
|
||||
resolve(request.result)
|
||||
}
|
||||
request.onerror = (event) => {
|
||||
reject(request.error)
|
||||
}
|
||||
})
|
||||
}
|
115
mode-tabs.js
115
mode-tabs.js
@ -1,115 +0,0 @@
|
||||
const app = window.app ??= {}
|
||||
const view = window.view ??= {}
|
||||
|
||||
import * as browser from './browser.js'
|
||||
import * as player from './player.js'
|
||||
import * as queue from './queue.js'
|
||||
|
||||
export let modules
|
||||
export const states = {
|
||||
PLAYER: 0,
|
||||
BROWSER: 1,
|
||||
QUEUE: 2
|
||||
}
|
||||
|
||||
export const Init = (name) => {
|
||||
app.mode = 1
|
||||
}
|
||||
|
||||
export const View = async () => {
|
||||
view.tab = {
|
||||
focusedGapLeft: 0
|
||||
}
|
||||
view.tab.indicator = document.getElementById('tab-indicator')
|
||||
view.tab.indicatorLine = document.getElementById('tab-indicator-line')
|
||||
view.tab.bar = document.getElementById('tabs')
|
||||
|
||||
modules = new Array(3)
|
||||
modules[states.PLAYER] = player
|
||||
modules[states.BROWSER] = browser
|
||||
modules[states.QUEUE] = queue
|
||||
|
||||
view.tab.order = []
|
||||
|
||||
for(let m of modules) {
|
||||
let t = renderTab()
|
||||
view.tab.order.push(t)
|
||||
await m.View(t)
|
||||
}
|
||||
Render()
|
||||
}
|
||||
|
||||
export const renderTab = () => {
|
||||
let e = document.createElement('button')
|
||||
view.tab.bar.appendChild(e)
|
||||
return e
|
||||
}
|
||||
|
||||
export const Display = (name) => {
|
||||
document.body.classList = [ name ]
|
||||
}
|
||||
|
||||
export const Render = () => {
|
||||
modules[app.mode].Open()
|
||||
|
||||
changeFocus()
|
||||
updateIndicator()
|
||||
scrollToFocus()
|
||||
}
|
||||
|
||||
export const changeFocus = () => {
|
||||
view.tab.focus?.classList.remove('selected')
|
||||
view.tab.focus = view.tab.order[app.mode]
|
||||
view.tab.focus.classList.add('selected')
|
||||
}
|
||||
|
||||
export const scrollToFocus = () => {
|
||||
view.tab.bar.scrollLeft = view.tab.focus.offsetLeft - view.tab.focusedGapLeft
|
||||
}
|
||||
|
||||
export const indicatorMargin = 2
|
||||
export const updateIndicator = () => {
|
||||
let before = 0
|
||||
let dashes = []
|
||||
let r = 100 - (indicatorMargin * (modules.length - 1))
|
||||
let pd = getPrimaryDashWidth()
|
||||
let sdc = modules.length - 1
|
||||
let sdr = r - pd
|
||||
let sd = parseInt(sdr / sdc)
|
||||
pd += sdr % sdc
|
||||
|
||||
console.log(r, sdc, pd, sd)
|
||||
|
||||
for(let i = 0; i < modules.length; i++) {
|
||||
if(i < app.mode) {
|
||||
before += sd + indicatorMargin
|
||||
}
|
||||
dashes.push(i === app.mode ? pd : sd)
|
||||
}
|
||||
|
||||
console.log(dashes, view)
|
||||
|
||||
view.tab.indicatorLine.style.strokeDasharray = dashes
|
||||
.join(', ' + indicatorMargin + ', ')
|
||||
view.tab.focusedGapLeft = (before / 100) * view.tab.indicator.clientWidth
|
||||
}
|
||||
|
||||
export const getPrimaryDashWidth = () => {
|
||||
return parseInt( (view.tab.focus.clientWidth / view.tab.indicator.clientWidth) * 100 )
|
||||
}
|
||||
|
||||
export const Set = (iMode) => {
|
||||
app.mode = iMode
|
||||
Render()
|
||||
}
|
||||
|
||||
export const Scroll = (distance) => {
|
||||
let i = distance + app.mode
|
||||
if(i < 0) i = modules.length - 1
|
||||
else if(i >= modules.length) i = 0
|
||||
Set(i)
|
||||
}
|
||||
|
||||
export const OnKeydown = (event) => {
|
||||
return modules[app.mode].OnKeydown(event)
|
||||
}
|
3
music-metadata.js
Normal file
3
music-metadata.js
Normal file
@ -0,0 +1,3 @@
|
||||
import { parseBlob } from 'music-metadata'
|
||||
|
||||
export { parseBlob }
|
108
old.index.html
108
old.index.html
@ -1,108 +0,0 @@
|
||||
<head>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- start injection -->
|
||||
<script src="./jsmediatags.min.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
<!-- end injection -->
|
||||
<script type="module" src="src/index.js"></script>
|
||||
<link rel="stylesheet" href="assets/main.css"></link>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- <section class="controls">
|
||||
|
||||
<header>
|
||||
|
||||
<h3 id="track-title">Bocce</h3>
|
||||
<p id="track-artist">Patricia Taxxon</p>
|
||||
|
||||
</header>
|
||||
<nav>
|
||||
|
||||
<button id="prev">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" baseProfile="full" version="1.1" viewbox="0 0 120 120" preserveAspectRatio="xMidYMid meet">
|
||||
<path d="M15,60 L15,60 L60,105 L60,15 L15,60 M60,60 L60,60 L105,15 L105,105 L60,60 "/>
|
||||
</svg>
|
||||
|
||||
</button>
|
||||
<button id="playpause">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" baseProfile="full" version="1.1" viewbox="0 0 105 120" preserveAspectRatio="xMidYMid meet">
|
||||
<path id="pause-icon" d="M15,15 L15,15 L15,105 L45,105 L45,15 L15,15 M60,15 L60,15 L60,105 L90,105 L90,15 L60,15 "/>
|
||||
<path id="play-icon" d="M30,15 L30,15 L30,105 L90,60 L30,15 "/>
|
||||
</svg>
|
||||
|
||||
</button>
|
||||
<button id="next">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" baseProfile="full" version="1.1" viewbox="0 0 120 120" preserveAspectRatio="xMidYMid meet">
|
||||
<path d="M60,60 L60,60 L15,105 L15,15 L60,60 M105,60 L105,60 L60,15 L60,105 L105,60 " />
|
||||
</svg>
|
||||
|
||||
</button>
|
||||
|
||||
</nav>
|
||||
|
||||
|
||||
</section> -->
|
||||
|
||||
<svg viewBox="0 0 100 2" xmlns="http://www.w3.org/2000/svg" id="tab-indicator">
|
||||
<!-- No dashes nor gaps -->
|
||||
<line x1="0" y1="0" x2="100" y2="0" stroke-width="3" id="tab-indicator-line" />
|
||||
</svg>
|
||||
<!-- <header id="tabs"></header> -->
|
||||
<!-- <header>
|
||||
<b>Browser</b>
|
||||
</header> -->
|
||||
<main id="main">
|
||||
<section class="player">
|
||||
|
||||
<img id="track-cover">
|
||||
<cite id="track-title">No Track Selected</cite>
|
||||
<cite id="track-artist">Unknown Artist</cite>
|
||||
|
||||
<code id="timestamp">--:--</code>
|
||||
<code id="track-length">--:--</code>
|
||||
|
||||
</section>
|
||||
<section class="browser">
|
||||
|
||||
<header>
|
||||
<h4>Browser</h4>
|
||||
</header>
|
||||
<menu id="browser"></menu>
|
||||
|
||||
</section>
|
||||
<section class="queue">
|
||||
|
||||
<header>
|
||||
<h4>Queue</h4>
|
||||
|
||||
</header>
|
||||
<menu id="queue"></menu>
|
||||
|
||||
</section>
|
||||
<section id="loading">
|
||||
|
||||
<header>— Loading —</header>
|
||||
<small id="loading-status"></small>
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<svg viewBox="0 0 100 1" xmlns="http://www.w3.org/2000/svg" class="progress">
|
||||
<!-- No dashes nor gaps -->
|
||||
<line x1="0" y1="0" x2="100" y2="0" stroke="currentColor" stroke-width="3" id="playback-progress" />
|
||||
</svg>
|
||||
<nav>
|
||||
|
||||
<button id="left">Previous</button>
|
||||
<button id="center">Paused</button>
|
||||
<button id="right">Next</button>
|
||||
|
||||
</nav>
|
||||
|
||||
</body>
|
127
old.src/entry.js
127
old.src/entry.js
@ -1,127 +0,0 @@
|
||||
const app = window.app ??= {}
|
||||
const view = window.view ??= {}
|
||||
|
||||
import * as device from 'device'
|
||||
import Store from 'store'
|
||||
|
||||
// TODO: decouple from app.browser and add loading bar
|
||||
|
||||
export let covers
|
||||
|
||||
export const Start = async () => {
|
||||
covers = await Store.Open('cover', 'data')
|
||||
}
|
||||
|
||||
export const Init = async () => {
|
||||
view.loadingStatus = document.getElementById('loading-status')
|
||||
app.trackCount = 0
|
||||
app.index = await Entries()
|
||||
}
|
||||
|
||||
export const Entries = async (hHandle, eParent) => {
|
||||
let dirE = await device.Entries(hHandle)
|
||||
let out = []
|
||||
|
||||
for(let [ n, h ] of dirE) {
|
||||
let e = await Create(n, h, eParent)
|
||||
if(e != undefined) {
|
||||
out.push(e)
|
||||
displayTrackFound(e)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export const displayTrackFound = (entry) => {
|
||||
app.trackCount++
|
||||
view.loadingStatus.innerText = `${entry.handle.name}\n(${app.trackCount})`
|
||||
}
|
||||
|
||||
export const trackFiletypes = [
|
||||
'.mp3',
|
||||
'.ogg',
|
||||
'.wav',
|
||||
'.flac'
|
||||
]
|
||||
|
||||
export const Create = async (sName, hHandle, eParent) => {
|
||||
let e = {
|
||||
handle: hHandle
|
||||
}
|
||||
|
||||
if(eParent) {
|
||||
e.parent = eParent
|
||||
}
|
||||
|
||||
switch(hHandle.kind) {
|
||||
case 'directory':
|
||||
e.entries = await Entries(hHandle, e)
|
||||
e.name = sName
|
||||
break
|
||||
|
||||
case 'file':
|
||||
let ext = sName.slice(sName.lastIndexOf('.'))
|
||||
if(trackFiletypes.includes(ext)) {
|
||||
await Track(e)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
e.order = e.trackNumber ?? 0
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
const Track = async e => {
|
||||
let md = await Metadata(e)
|
||||
|
||||
e.album = md.album
|
||||
e.name = md.title
|
||||
e.artist = md.artist ?? "Unknown Artist"
|
||||
e.trackNumber = md.track ? parseInt(md.track) : Infinity
|
||||
e.lyrics = md.lyrics?.lyrics && new Map([ [ 0, md.lyrics?.lyrics ] ])
|
||||
e.coverId = md.picture && await IndexCover(md.picture)
|
||||
}
|
||||
|
||||
export const IndexCover = async ({ format, data }) => {
|
||||
let id = format + '-' + data.length
|
||||
|
||||
// TODO: don't get, just check if has
|
||||
let c = await covers.Get(id)
|
||||
.catch(err => {})
|
||||
if(c == null) {
|
||||
c = new Blob([ new Uint8Array(data) ], { type: format })
|
||||
await covers.Set(id, c)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export const GetCover = (eTrack) => {
|
||||
return covers.Get(eTrack.coverId)
|
||||
}
|
||||
|
||||
const Metadata = async e => {
|
||||
let d = await device.Open(e.handle).then(mediaTags)
|
||||
.catch(e => console.error(e))
|
||||
|
||||
if(!d) {
|
||||
return /((?<track>\d+)\s+)?((?<artist>.+)-\s*)?\s*(?<title>.+)\s*\.\w+/
|
||||
.exec(e.handle.name)
|
||||
.groups
|
||||
} else {
|
||||
return d.tags
|
||||
}
|
||||
}
|
||||
|
||||
const mediaTags = blob => new Promise((resolve, reject) => {
|
||||
jsmediatags.read(blob, {
|
||||
onSuccess(tags) {
|
||||
resolve(tags)
|
||||
},
|
||||
onError(err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
@ -1,14 +0,0 @@
|
||||
import * as main from './main.js'
|
||||
import * as entry from './entry.js'
|
||||
import * as device from 'device'
|
||||
|
||||
window.device = device
|
||||
window.browser = browser
|
||||
window.queue = queue
|
||||
window.entry = entry
|
||||
window.mode = mode
|
||||
window.player = player
|
||||
window.input = input
|
||||
window.main = main
|
||||
|
||||
document.addEventListener('load', main.Start())
|
110
old.src/main.js
110
old.src/main.js
@ -1,110 +0,0 @@
|
||||
const app = window.app ??= {}
|
||||
const view = window.view ??= {}
|
||||
|
||||
import Store from 'store'
|
||||
import * as device from 'device'
|
||||
import * as view from './view/index.js'
|
||||
import * as entry from './entry.js'
|
||||
|
||||
export let store
|
||||
export let saveTimeout
|
||||
|
||||
export const Start = async () => {
|
||||
store = await Store.Open('app', 'data')
|
||||
await device.Start()
|
||||
await entry.Start()
|
||||
await Init()
|
||||
|
||||
view.loading.remove()
|
||||
}
|
||||
|
||||
export const Init = async () => {
|
||||
let success = await loadState()
|
||||
|
||||
if(!success) {
|
||||
await view.Init()
|
||||
}
|
||||
|
||||
window.addEventListener('visibilitychange', async event => {
|
||||
if(document.visibilityState !== 'visible') {
|
||||
await saveState()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const View = async () => {
|
||||
view.main = document.getElementById('main')
|
||||
view.loading = document.getElementById('loading')
|
||||
view.loadingStatus = document.getElementById('loading-status')
|
||||
|
||||
await browser.View()
|
||||
await queue.View()
|
||||
await player.View()
|
||||
mode.View()
|
||||
input.View()
|
||||
|
||||
// player.Render({ name: 'Timeland, Smoke & Mirrors, The Land Before Timeland, & Hypertension', artist: 'King Gizzard & The Lizard Wizard' })
|
||||
}
|
||||
|
||||
export const loadState = async () => {
|
||||
let state = await store.Get('state')
|
||||
Object.assign(app, state)
|
||||
|
||||
return state != undefined
|
||||
}
|
||||
|
||||
export const saveState = () => {
|
||||
app.lastSaved = Date.now()
|
||||
console.log('saving')
|
||||
return store.Set('state', app)
|
||||
}
|
||||
|
||||
export const resetSaveTimer = () => {
|
||||
if(saveTimeout)
|
||||
clearTimeout(saveTimeout)
|
||||
saveTimeout = setTimeout(() => saveState(), 10 * 1000)
|
||||
}
|
||||
|
||||
export const Reload = async () => {
|
||||
await store.Set('state', null)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
export const OnKeydown = (event) => {
|
||||
switch(event.key) {
|
||||
case 'EndCall':
|
||||
saveState().then(() => window.close())
|
||||
break
|
||||
|
||||
case '1':
|
||||
mode.Set(mode.states.PLAYER)
|
||||
break
|
||||
|
||||
case '2':
|
||||
mode.Set(mode.states.BROWSER)
|
||||
break
|
||||
|
||||
case '3':
|
||||
mode.Set(mode.states.QUEUE)
|
||||
break
|
||||
|
||||
case '0':
|
||||
main.Reload()
|
||||
break
|
||||
|
||||
case 'ArrowLeft':
|
||||
mode.Scroll(-1)
|
||||
break
|
||||
|
||||
case 'ArrowRight':
|
||||
mode.Scroll(1)
|
||||
break
|
||||
|
||||
default:
|
||||
if(mode.OnKeydown(event))
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
resetSaveTimer()
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import * as device from 'device'
|
||||
|
||||
export let preloadDepth = 3
|
||||
|
||||
export const Init = () => {
|
||||
app.preload = {}
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
export default function List(view) {
|
||||
|
||||
view.cursor = 0
|
||||
|
||||
const Render = this.Render = (entries, iResetCursorTo = view.cursor) => {
|
||||
while (view.element.firstChild) {
|
||||
view.element.removeChild(view.element.lastChild)
|
||||
}
|
||||
entries.map(renderEntry).forEach(n => view.element.appendChild(n))
|
||||
Focus(iResetCursorTo)
|
||||
}
|
||||
|
||||
const renderEntry = (entry, i) => {
|
||||
let ele = document.createElement('li')
|
||||
|
||||
if(entry.entries) {
|
||||
populateListEntry(entry, ele, i)
|
||||
} else {
|
||||
populateTrackEntry(entry, ele, i)
|
||||
}
|
||||
|
||||
// ele.addEventListener('mouseenter', (event) => {
|
||||
// console.log(event)
|
||||
// if(event.movementX || event.movementY)
|
||||
// Focus(i, false)
|
||||
// })
|
||||
|
||||
return ele
|
||||
}
|
||||
|
||||
const populateListEntry = (entry, ele) => {
|
||||
let a = document.createElement('a')
|
||||
let header = document.createElement('header')
|
||||
|
||||
header.innerText = entry.name
|
||||
a.appendChild(header)
|
||||
ele.appendChild(a)
|
||||
}
|
||||
|
||||
const populateTrackEntry = (entry, ele) => {
|
||||
// TODO: fix button problem on kaios
|
||||
let sect = document.createElement('section')
|
||||
|
||||
let trackNumber = document.createElement('code')
|
||||
let title = document.createElement('b')
|
||||
let artist = document.createElement('cite')
|
||||
|
||||
trackNumber.innerText = formatTrackNumber(entry.trackNumber)
|
||||
title.innerText = entry.name
|
||||
artist.innerText = entry.artist
|
||||
|
||||
sect.appendChild(title)
|
||||
sect.appendChild(artist)
|
||||
|
||||
ele.appendChild(trackNumber)
|
||||
ele.appendChild(sect)
|
||||
}
|
||||
|
||||
const formatTrackNumber = trackNumber => {
|
||||
return trackNumber == Infinity ? '--' : trackNumber.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
const Scroll = this.Scroll = (distance) => {
|
||||
let children = Array.from(view.element.children)
|
||||
let i = view.cursor
|
||||
i += distance
|
||||
if(i >= children.length) {
|
||||
i = 0
|
||||
} else if(i < 0) {
|
||||
i = children.length - 1
|
||||
}
|
||||
|
||||
Focus(i)
|
||||
}
|
||||
|
||||
const Focus = this.Focus = (i, scroll = true) => {
|
||||
view.cursor = i
|
||||
if(view.element.children.length === 0) {
|
||||
return
|
||||
}
|
||||
if(view.focus) {
|
||||
view.focus.classList.remove('focus')
|
||||
}
|
||||
view.focus = view.element.children[view.cursor]
|
||||
view.focus.classList.add('focus')
|
||||
if(scroll) {
|
||||
view.element.children[Math.max(i - 4, 0)].scrollIntoView()
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
@ -1,109 +0,0 @@
|
||||
// entry - a track, directory, playlist, etc
|
||||
// - entries
|
||||
// - name
|
||||
// - length
|
||||
|
||||
import List from "./List.js"
|
||||
import * as panels from "./panels.js"
|
||||
|
||||
const app = window.app ??= {}
|
||||
const view = window.view ??= {}
|
||||
|
||||
export let list = new List(view.browser = {
|
||||
element: document.getElementById('browser')
|
||||
})
|
||||
|
||||
export const Init = async () => {
|
||||
app.browser = {}
|
||||
app.browser.root = {
|
||||
entries: app.index,
|
||||
root: true
|
||||
}
|
||||
app.browser.current = app.browser.root
|
||||
app.browser.recursiveView = false
|
||||
app.browser.view = []
|
||||
}
|
||||
|
||||
export const View = async () => {
|
||||
Render()
|
||||
}
|
||||
|
||||
export const Render = async () => {
|
||||
organizeView()
|
||||
list.Render(app.browser.view, 0)
|
||||
}
|
||||
|
||||
export const organizeView = () => {
|
||||
if(app.browser.recursiveView) {
|
||||
app.browser.view = []
|
||||
recurseView()
|
||||
} else {
|
||||
app.browser.view = app.browser.current.entries
|
||||
}
|
||||
app.browser.view.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
|
||||
export const recurseView = (eaEntries = app.browser.current.entries, bIncludeDirectories = true) => {
|
||||
for(let e of eaEntries) {
|
||||
if(e.entries) {
|
||||
recurseView(e.entries, false)
|
||||
if(!bIncludeDirectories)
|
||||
continue
|
||||
}
|
||||
app.browser.view.push(e)
|
||||
}
|
||||
}
|
||||
|
||||
export const Open = () => {
|
||||
panels.Display('browser')
|
||||
}
|
||||
|
||||
export const Back = () => {
|
||||
let e = app.browser.current
|
||||
if(e.root) {
|
||||
return
|
||||
}
|
||||
let s = Cd(e.parent)
|
||||
|
||||
if(s) {
|
||||
list.Focus(app.browser.view.indexOf(e))
|
||||
}
|
||||
}
|
||||
|
||||
export const Cd = (entry = Focused() || app.browser.root) => {
|
||||
if(entry.entries) {
|
||||
app.browser.current = entry
|
||||
Render()
|
||||
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const Queue = (entry = Focused()) => {
|
||||
queue.Add(entry)
|
||||
list.Scroll(1)
|
||||
}
|
||||
|
||||
export const QueueAllBelow = () => {
|
||||
let e = app.browser.current.entries
|
||||
|
||||
for(let i = app.cursor; i < e.length; i++) {
|
||||
queue.Add(e)
|
||||
}
|
||||
}
|
||||
|
||||
export const GoToDirectory = (entry = Focused()) => {
|
||||
Cd(entry.parent) &&
|
||||
list.Focus(app.browser.view.indexOf(entry))
|
||||
}
|
||||
|
||||
export const ToggleRecursiveView = () => {
|
||||
app.browser.recursiveView = !app.browser.recursiveView
|
||||
Render()
|
||||
}
|
||||
|
||||
export const Focused = () => {
|
||||
return app.browser.view[view.browser.cursor]
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
import * as mode from './panels.js'
|
||||
import * as queue from './queue.js'
|
||||
import * as browser from './browser.js'
|
||||
import * as player from './player.js'
|
||||
import * as input from './input.js'
|
||||
import * as List from './List.js'
|
||||
|
||||
export {
|
||||
mode,
|
||||
queue,
|
||||
browser,
|
||||
player,
|
||||
input,
|
||||
List
|
||||
}
|
||||
|
||||
export function toggleHidden(element) {
|
||||
element.classList.toggle('hidden')
|
||||
}
|
@ -1,211 +0,0 @@
|
||||
const app = window.app ??= {}
|
||||
const view = window.view ??= {}
|
||||
|
||||
const holdIntervals = 3
|
||||
const holdIntervalLength = 200
|
||||
|
||||
import * as browser from './browser.js'
|
||||
import * as player from './player.js'
|
||||
import * as queue from './queue.js'
|
||||
import * as main from '../main.js'
|
||||
|
||||
{
|
||||
window.addEventListener('keydown', OnKeydown)
|
||||
window.addEventListener('keyup', OnKeyup)
|
||||
}
|
||||
|
||||
export function Render() {
|
||||
Actions()
|
||||
}
|
||||
|
||||
export function Actions() {
|
||||
let a = view.actions = {}
|
||||
|
||||
a['EndCall'] = {
|
||||
name: 'Close Hiss',
|
||||
up() {
|
||||
main.saveState().then(() => window.close())
|
||||
}
|
||||
}
|
||||
|
||||
a['ArrowRight'] = {
|
||||
name: 'Next Panel',
|
||||
up() {
|
||||
mode.Scroll(1)
|
||||
}
|
||||
}
|
||||
a['ArrowLeft'] = {
|
||||
name: 'Prev Panel',
|
||||
up() {
|
||||
mode.Scroll(-1)
|
||||
}
|
||||
}
|
||||
a['0'] = {
|
||||
|
||||
}
|
||||
|
||||
a['c'] = a.pausePlay = {
|
||||
hint: 'Pause/Play',
|
||||
up: player.TogglePausePlay
|
||||
}
|
||||
a['s'] = a.skip = {
|
||||
hint: 'Next',
|
||||
up: player.Next
|
||||
}
|
||||
a['+'] = a.volumeUp = {
|
||||
hint: 'Volume Up',
|
||||
down() {
|
||||
navigator.volumeUp()
|
||||
}
|
||||
}
|
||||
a['-'] = a.volumeDown = {
|
||||
hint: 'Volume Down',
|
||||
down() {
|
||||
navigator.volumeDown()
|
||||
}
|
||||
}
|
||||
|
||||
switch(app.mode) {
|
||||
case mode.states.PLAYER:
|
||||
a[' '] = a['Enter'] = player.State() == player.states.PLAYING ?
|
||||
{
|
||||
hint: 'Play',
|
||||
up: player.Play
|
||||
} :
|
||||
{
|
||||
hint: 'Pause',
|
||||
up: player.Pause
|
||||
}
|
||||
a['ArrowUp'] = a.volumeUp
|
||||
a['ArrowDown'] = a.volumeDown
|
||||
a['SoftRight'] = a.skip
|
||||
break
|
||||
|
||||
case mode.states.BROWSER:
|
||||
a['q'] = {
|
||||
down: browser.Queue
|
||||
}
|
||||
a[' '] = a['Enter'] = {
|
||||
hint: 'Queue',
|
||||
press() {
|
||||
browser.Cd() && browser.Queue()
|
||||
},
|
||||
holdHint: 'Queue All',
|
||||
hold: browser.QueueAllBelow
|
||||
}
|
||||
a['4'] = a['g'] = {
|
||||
up: browser.GoToDirectory
|
||||
}
|
||||
a['Backspace'] = a['Escape'] = {
|
||||
up: browser.Back
|
||||
}
|
||||
|
||||
listActions(browser.list)
|
||||
break
|
||||
|
||||
case mode.states.QUEUE:
|
||||
listActions(queue.list)
|
||||
|
||||
a[' '] = a['Enter'] = {
|
||||
hint: 'Remove',
|
||||
press: queue.Remove,
|
||||
holdHint: 'Remove All',
|
||||
hold: queue.RemoveAllBelow
|
||||
}
|
||||
a['SoftLeft'] = a['['] = a['ArrowUp'].shift = {
|
||||
down() {
|
||||
queue.Move(-1)
|
||||
}
|
||||
}
|
||||
a['SoftRight'] = a[']'] = a['ArrowDown'].shift = {
|
||||
down() {
|
||||
queue.Move(1)
|
||||
}
|
||||
}
|
||||
a['4'] = a['g'] = {
|
||||
up() {
|
||||
browser.GoToDirectory(queue.Focused())
|
||||
mode.Set(mode.states.BROWSER)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function listActions(list) {
|
||||
let a = view.actions
|
||||
|
||||
a['ArrowUp'] = {
|
||||
down() {
|
||||
list.Scroll(-1)
|
||||
}
|
||||
}
|
||||
a['ArrowDown'] = {
|
||||
down() {
|
||||
list.Scroll(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function OnKeydown(event) {
|
||||
let a = view.actions[event.key]
|
||||
if(a == null) return
|
||||
|
||||
ActionDown(a.shift && event.shiftKey ? a.shift : a)
|
||||
&& event.preventDefault()
|
||||
}
|
||||
|
||||
export function ActionDown(action) {
|
||||
if(action.holdTimeout != null) {
|
||||
return true
|
||||
}
|
||||
|
||||
if(action.down) {
|
||||
return action.down()
|
||||
}
|
||||
|
||||
if(action.hold) {
|
||||
Hold(action)
|
||||
} else if(action.press) {
|
||||
return action.press()
|
||||
}
|
||||
}
|
||||
|
||||
export function OnKeyup(event) {
|
||||
let a = view.actions[event.key]
|
||||
if(a == null) return
|
||||
|
||||
ActionUp(a.shift && event.shiftKey ? a.shift : a)
|
||||
&& event.preventDefault()
|
||||
}
|
||||
|
||||
export function ActionUp(action) {
|
||||
if(action.holdTimeout != null) {
|
||||
clearTimeout(action.holdTimeout)
|
||||
action.holdTimeout = null
|
||||
}
|
||||
|
||||
if(action.up) {
|
||||
return action.up()
|
||||
}
|
||||
|
||||
if(action.heldCount > 0) {
|
||||
return action.press()
|
||||
}
|
||||
}
|
||||
|
||||
export function Hold(aAction) {
|
||||
aAction.heldCount = holdIntervals
|
||||
createHoldTimeout(aAction)
|
||||
}
|
||||
|
||||
export function createHoldTimeout(aAction) {
|
||||
aAction.holdTimeout = setTimeout(() => {
|
||||
aAction.heldCount--
|
||||
if(aAction.heldCount == 0) {
|
||||
aAction.hold()
|
||||
} else {
|
||||
createHoldTimeout(aAction)
|
||||
}
|
||||
}, holdIntervalLength)
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
const app = window.app ??= {}
|
||||
const view = window.view ??= {}
|
||||
|
||||
import * as browser from './browser.js'
|
||||
import * as player from './player.js'
|
||||
import * as queue from './queue.js'
|
||||
import * as input from './input.js'
|
||||
|
||||
export let modules
|
||||
export const states = {
|
||||
PLAYER: 0,
|
||||
BROWSER: 1,
|
||||
QUEUE: 2
|
||||
}
|
||||
|
||||
export const Init = (name) => {
|
||||
app.mode = 1
|
||||
}
|
||||
|
||||
export const View = () => {
|
||||
view.tabIndicatorLine = document.getElementById('tab-indicator-line')
|
||||
|
||||
modules = new Array(3)
|
||||
modules[states.PLAYER] = player
|
||||
modules[states.BROWSER] = browser
|
||||
modules[states.QUEUE] = queue
|
||||
|
||||
modules[app.mode].Open()
|
||||
|
||||
Render()
|
||||
input.Actions()
|
||||
}
|
||||
|
||||
export const Display = (name) => {
|
||||
document.body.classList = [ name ]
|
||||
}
|
||||
|
||||
export const Render = () => {
|
||||
updateIndicator()
|
||||
}
|
||||
|
||||
export const indicatorMargin = 2
|
||||
export const updateIndicator = () => {
|
||||
let dashes = []
|
||||
let r = 100 - (indicatorMargin * (modules.length - 1))
|
||||
let pd = 60
|
||||
let sdc = modules.length - 1
|
||||
let sdr = r - pd
|
||||
let sd = parseInt(sdr / sdc)
|
||||
pd += sdr % sdc
|
||||
|
||||
for(let i = 0; i < modules.length; i++) {
|
||||
dashes.push(i === app.mode ? pd : sd)
|
||||
}
|
||||
|
||||
view.tabIndicatorLine.style.strokeDasharray = dashes
|
||||
.join(', ' + indicatorMargin + ', ')
|
||||
|
||||
}
|
||||
|
||||
export const Set = (iMode) => {
|
||||
app.mode = iMode
|
||||
View()
|
||||
}
|
||||
|
||||
export const Scroll = (distance) => {
|
||||
let i = distance + app.mode
|
||||
if(i < 0) i = modules.length - 1
|
||||
else if(i >= modules.length) i = 0
|
||||
Set(i)
|
||||
}
|
||||
|
||||
export const OnKeydown = (event) => {
|
||||
return modules[app.mode].OnKeydown(event)
|
||||
}
|
@ -1,226 +0,0 @@
|
||||
const view = window.view ??= {}
|
||||
const app = window.app ??= {}
|
||||
|
||||
import * as mode from "./panels.js"
|
||||
import * as queue from "./queue.js"
|
||||
import * as entry from "../entry.js"
|
||||
|
||||
export const states = {
|
||||
EMPTY: 0,
|
||||
PLAYING: 1,
|
||||
PAUSED: 2
|
||||
}
|
||||
|
||||
export const Init = () => {
|
||||
app.player = {
|
||||
currentTime: 0
|
||||
}
|
||||
}
|
||||
|
||||
export const View = async () => {
|
||||
view.player = {
|
||||
cover: document.getElementById('track-cover'),
|
||||
title: document.getElementById('track-title'),
|
||||
artist: document.getElementById('track-artist'),
|
||||
timestamp: document.getElementById('timestamp'),
|
||||
trackLength: document.getElementById('track-length'),
|
||||
progress: document.getElementById('playback-progress')
|
||||
}
|
||||
|
||||
if(app.player.current) {
|
||||
Switch(await Load(app.player.current, app.player.currentTime), app.player.current)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const Open = () => {
|
||||
mode.Display('player')
|
||||
}
|
||||
|
||||
export const TogglePausePlay = () => {
|
||||
if(State() === states.PLAYING) {
|
||||
Pause()
|
||||
} else {
|
||||
Play()
|
||||
}
|
||||
}
|
||||
|
||||
export const OnKeydown = (event) => {
|
||||
switch(event.key) {
|
||||
case 'ArrowUp':
|
||||
navigator.volumeManager?.requestUp()
|
||||
break
|
||||
|
||||
case 'ArrowDown':
|
||||
navigator.volumeManager?.requestDown()
|
||||
break
|
||||
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
if(State() === states.PLAYING) {
|
||||
Pause()
|
||||
} else {
|
||||
Play()
|
||||
}
|
||||
break
|
||||
|
||||
case ']':
|
||||
case 'SoftRight':
|
||||
Next()
|
||||
break
|
||||
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const Load = async (eTrack, fromTime) => {
|
||||
let f = await device.Open(eTrack.handle)
|
||||
let u = URL.createObjectURL(f)
|
||||
|
||||
// if(fromTime) {
|
||||
// u = new URL(u)
|
||||
// u.hash = '#t=' + fromTime
|
||||
// }
|
||||
let a = new Audio(u)
|
||||
if(fromTime) {
|
||||
a.currentTime = fromTime
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
export const Switch = (aTrack, eTrack) => {
|
||||
if(State() === states.PLAYING) {
|
||||
view.playback.pause()
|
||||
}
|
||||
|
||||
view.playback = aTrack
|
||||
app.player.current = eTrack
|
||||
attach()
|
||||
Render()
|
||||
}
|
||||
|
||||
export const attach = () => {
|
||||
let a = view.playback
|
||||
|
||||
a.addEventListener('playing', onStart)
|
||||
a.addEventListener('timeupdate', onTimeUpdate)
|
||||
a.addEventListener('ended', onEnded)
|
||||
|
||||
storeTime()
|
||||
}
|
||||
|
||||
export const onStart = () => {
|
||||
storeTime()
|
||||
renderDuration()
|
||||
onTimeUpdate()
|
||||
}
|
||||
|
||||
export const onTimeUpdate = () => {
|
||||
let s = parseInt(view.playback.currentTime)
|
||||
if(s > app.player.currentTime) {
|
||||
storeTime()
|
||||
renderTimestamp()
|
||||
}
|
||||
renderProgress()
|
||||
}
|
||||
|
||||
export const storeTime = () => {
|
||||
app.player.currentTime = view.playback ?
|
||||
parseInt(view.playback.currentTime) :
|
||||
0
|
||||
}
|
||||
|
||||
export const formatTime = currentTime => {
|
||||
let minutes = Math.floor(currentTime / 60).toString()
|
||||
let seconds = Math.floor(currentTime % 60).toString()
|
||||
|
||||
return minutes.padStart(2, '0') + ':' + seconds.padStart(2, '0')
|
||||
}
|
||||
|
||||
export const onEnded = () => {
|
||||
view.playback = null
|
||||
Next()
|
||||
}
|
||||
|
||||
/* Rendering */
|
||||
|
||||
export const Render = async () => {
|
||||
marqueeableText(view.player.title, app.player.current.name)
|
||||
marqueeableText(view.player.artist, app.player.current.artist)
|
||||
renderCover()
|
||||
renderTimestamp()
|
||||
renderDuration()
|
||||
}
|
||||
|
||||
export const marqueeableText = (eElement, sName) => {
|
||||
while(eElement.firstChild) {
|
||||
eElement.lastChild.remove()
|
||||
}
|
||||
eElement.classList = []
|
||||
let s = textContainer(eElement, sName)
|
||||
|
||||
if(s.offsetWidth > eElement.offsetWidth) {
|
||||
eElement.classList = [ 'marquee' ]
|
||||
textContainer(eElement, sName)
|
||||
}
|
||||
}
|
||||
|
||||
export const textContainer = (eElement, sText) => {
|
||||
let s = document.createElement('span')
|
||||
s.innerText = sText
|
||||
eElement.appendChild(s)
|
||||
return s
|
||||
}
|
||||
|
||||
export const renderProgress = () => {
|
||||
let t = view.playback.currentTime / view.playback.duration
|
||||
t = parseInt(t * 100)
|
||||
view.player.progress.style.strokeDasharray = `${t}, ${100 - t}`
|
||||
}
|
||||
|
||||
export const renderTimestamp = (iTimestamp = app.player.currentTime) => {
|
||||
view.player.timestamp.innerText = formatTime(iTimestamp)
|
||||
}
|
||||
|
||||
export const renderDuration = (iDuration = view.playback.duration) => {
|
||||
view.player.trackLength.innerText = formatTime(iDuration)
|
||||
}
|
||||
|
||||
export const renderCover = async () => {
|
||||
view.player.cover.src = app.player.current.coverId ?
|
||||
URL.createObjectURL(await entry.GetCover(app.player.current)):
|
||||
''
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
|
||||
export const Play = async () => {
|
||||
if(view.playback) {
|
||||
view.playback.play()
|
||||
return
|
||||
}
|
||||
|
||||
await Next()
|
||||
}
|
||||
|
||||
export const Next = async () => {
|
||||
let e = queue.Shift()
|
||||
if(e) {
|
||||
Switch(await Load(e), e)
|
||||
view.playback.play()
|
||||
}
|
||||
}
|
||||
|
||||
export const Pause = () => {
|
||||
view.playback.pause()
|
||||
}
|
||||
|
||||
export const State = () => {
|
||||
if(view.playback == null) {
|
||||
return states.EMPTY
|
||||
} else {
|
||||
return view.playback.paused ? states.PAUSED : states.PLAYING
|
||||
}
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
import List from "./List.js"
|
||||
import * as mode from "./panels.js"
|
||||
import * as player from "./player.js"
|
||||
|
||||
const app = window.app ??= {}
|
||||
const view = window.view ??= {}
|
||||
|
||||
export let list = new List(view.queue = {
|
||||
element: document.getElementById('queue'),
|
||||
})
|
||||
|
||||
export const Init = async () => {
|
||||
app.queue = []
|
||||
}
|
||||
|
||||
export const View = async () => {
|
||||
Render()
|
||||
}
|
||||
|
||||
export const Render = async () => {
|
||||
list.Render(app.queue)
|
||||
}
|
||||
|
||||
export const Move = (offset, eTrack = Focused()) => {
|
||||
let i = view.queue.cursor + offset
|
||||
if(i == app.queue.length || i < 0) {
|
||||
return
|
||||
}
|
||||
let n = app.queue[i]
|
||||
app.queue[view.queue.cursor] = n
|
||||
app.queue[i] = eTrack
|
||||
list.Render(app.queue, i)
|
||||
}
|
||||
|
||||
export const Open = () => {
|
||||
Render()
|
||||
mode.Display('queue')
|
||||
}
|
||||
|
||||
export const Focused = () => {
|
||||
return app.queue[view.queue.cursor]
|
||||
}
|
||||
|
||||
export const Add = (eTrack) => {
|
||||
// TODO: solid way to determine if something is playable
|
||||
if(eTrack.entries) {
|
||||
return false
|
||||
}
|
||||
app.queue.push(eTrack)
|
||||
if(app.queue.length == 1 && player.State() === player.states.EMPTY) {
|
||||
player.Next()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const Remove = () => {
|
||||
app.queue.splice(view.queue.cursor, 1)
|
||||
Render()
|
||||
}
|
||||
|
||||
export const RemoveAllBelow = () => {
|
||||
app.queue = app.queue.slice(0, view.queue.cursor)
|
||||
Render()
|
||||
}
|
||||
|
||||
export const Shift = () => {
|
||||
let e
|
||||
|
||||
if(app.queue.length > 0) {
|
||||
e = app.queue.shift()
|
||||
Render()
|
||||
return e
|
||||
}
|
||||
|
||||
}
|
2899
package-lock.json
generated
2899
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -4,14 +4,15 @@
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/plugin-babel": "^6.0.4",
|
||||
"jsmediatags": "^3.9.7"
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"jsmediatags": "^3.9.7",
|
||||
"mp3tag": "^1.0.5",
|
||||
"mp3tag.js": "^3.11.0",
|
||||
"music-metadata": "^10.5.1",
|
||||
"music-metadata-browser": "^2.5.11",
|
||||
"rollup": "^4.24.0"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.25.9",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"rollup": "^4.27.2"
|
||||
}
|
||||
"type": "module"
|
||||
}
|
||||
|
1
pause.svg
Normal file
1
pause.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" baseProfile="full" version="1.1" width="750px" height="945px" style="stroke-width: 17; stroke-linecap: round; stroke-linejoin: round; stroke: currentcolor; fill: rgb(10, 10, 10);"><path d="M30,15 L30,15 L30,120 L45,120 L45,15 L30,15 L30,120 M90,15 L90,15 L90,120 L105,120 L105,15 L90,15 "/></svg>
|
After Width: | Height: | Size: 337 B |
@ -1,38 +1,23 @@
|
||||
import { babel } from '@rollup/plugin-babel';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve';
|
||||
|
||||
const map = {
|
||||
"device": "./device/kaios.js",
|
||||
"store": "./lib/store.js"
|
||||
}
|
||||
|
||||
const config = {
|
||||
input: 'src/main.js',
|
||||
output: {
|
||||
dir: 'build',
|
||||
format: 'iife'
|
||||
},
|
||||
plugins: [
|
||||
babel({
|
||||
babelHelpers: 'bundled',
|
||||
presets: [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"firefox": "48"
|
||||
},
|
||||
"modules": false
|
||||
}
|
||||
]
|
||||
]
|
||||
}),
|
||||
{
|
||||
resolveId(n) {
|
||||
if(map[n])
|
||||
return map[n]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default {
|
||||
input: './music-metadata.js',
|
||||
output: {
|
||||
dir: 'music-metadata',
|
||||
format: 'es'
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
resolveId(name) {
|
||||
if(name == 'strtok3') {
|
||||
return "/home/dakedres/projects/audioplayer/node_modules/strtok3/lib/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
nodeResolve({
|
||||
browser: true
|
||||
}),
|
||||
commonjs()
|
||||
]
|
||||
};
|
@ -1,54 +0,0 @@
|
||||
import Store from 'store'
|
||||
|
||||
export const db = await Store.Open('covers', 'data')
|
||||
|
||||
export async function IndexPicture(picture) {
|
||||
let id = picture.format + '-' + picture.data.length
|
||||
|
||||
let c = await db.Get(id)
|
||||
.catch(err => {})
|
||||
|
||||
if(c == null) {
|
||||
let blob = new Blob(
|
||||
[ new Uint8Array(picture.data) ],
|
||||
{ type: picture.format }
|
||||
)
|
||||
await db.Set(id, blob)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export async function Get(id) {
|
||||
return await db.Get(id)
|
||||
}
|
||||
|
||||
export async function Url(id) {
|
||||
return id ?
|
||||
URL.createObjectURL(await Get(id)) :
|
||||
null
|
||||
}
|
||||
|
||||
export async function Download(release) {
|
||||
let u = new URL('https://coverartarchive.org/release/' + release.id)
|
||||
let r = await fetch(u)
|
||||
|
||||
if(!r.ok)
|
||||
return
|
||||
|
||||
let d = await r.json()
|
||||
let i = d.images.find(i => i.types.includes('Front')) ?? d.images[0]
|
||||
return fetch(i.thumbnails ? i.thumbnails['250'] : i.image)
|
||||
.then(r => r.blob())
|
||||
}
|
||||
|
||||
export async function IndexRelease(release) {
|
||||
let c = await db.Get(release.id)
|
||||
.catch(err => {})
|
||||
|
||||
if(c == null) {
|
||||
let b = await Download(release)
|
||||
await db.Set(release.id, b)
|
||||
}
|
||||
|
||||
return release.id
|
||||
}
|
@ -1,153 +0,0 @@
|
||||
import * as device from 'device'
|
||||
import * as cover from './cover.js'
|
||||
import * as lyrics from './lyrics.js'
|
||||
import * as musicbrainz from './musicbrainz.js'
|
||||
|
||||
export {
|
||||
cover,
|
||||
lyrics,
|
||||
musicbrainz
|
||||
}
|
||||
|
||||
export const trackFiletypes = [
|
||||
'.wav',
|
||||
'.mp3',
|
||||
'.ogg',
|
||||
'.flac'
|
||||
]
|
||||
|
||||
export async function State() {
|
||||
hiss.state.entries = []
|
||||
hiss.state.index = await Entry('root', device.root)
|
||||
.catch(console.error)
|
||||
|
||||
for(let t of hiss.state.entries) {
|
||||
if(IsTrack(t)) {
|
||||
await Duration(t)
|
||||
if(musicbrainz.NeedsPolyfill(t)) {
|
||||
let r = await musicbrainz.Get(t)
|
||||
if(r) {
|
||||
musicbrainz.Polyfill(t, r)
|
||||
}
|
||||
}
|
||||
await lyrics.Download(t)
|
||||
}
|
||||
}
|
||||
|
||||
// for(let t of hiss.state.entries) {
|
||||
// if(IsTrack(t)) {
|
||||
// await lyrics.Download(t)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
export function IsTrack(entry) {
|
||||
return !entry.entries
|
||||
}
|
||||
|
||||
export async function Add(hHandle, eParent) {
|
||||
let dirE = await device.Entries(hHandle)
|
||||
let out = []
|
||||
|
||||
for(let [ n, h ] of dirE) {
|
||||
let e = await Entry(n, h, eParent)
|
||||
if(e != undefined) {
|
||||
out.push(e)
|
||||
// displayTrackFound(e)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export async function Entry(sName, hHandle, eParent) {
|
||||
let e = {
|
||||
handle: hHandle,
|
||||
id: hiss.state.entries.length
|
||||
}
|
||||
|
||||
if(eParent) {
|
||||
e.parent = eParent
|
||||
}
|
||||
|
||||
switch(hHandle.kind) {
|
||||
case 'directory':
|
||||
hiss.state.entries.push(e)
|
||||
e.tracks = []
|
||||
e.entries = await Add(hHandle, e)
|
||||
e.name = sName
|
||||
|
||||
if(e.parent) {
|
||||
e.parent.tracks = e.parent.tracks.concat(e.tracks)
|
||||
}
|
||||
break
|
||||
|
||||
case 'file':
|
||||
let ext = sName.slice(sName.lastIndexOf('.'))
|
||||
if(trackFiletypes.includes(ext)) {
|
||||
await Track(e)
|
||||
e.parent?.tracks.push(e)
|
||||
hiss.state.entries.push(e)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
e.order = e.trackNumber ?? 0
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
export async function Track(eTrack) {
|
||||
let md = await OpenMetadata(eTrack) ?? GenerateMetadata(eTrack)
|
||||
|
||||
eTrack.album = md.album
|
||||
eTrack.name = md.title
|
||||
eTrack.artist = md.artist
|
||||
eTrack.trackNumber = md.track ? parseInt(md.track) : Infinity
|
||||
eTrack.coverId = md.picture && await cover.IndexPicture(md.picture)
|
||||
if(md.lyrics?.lyrics) {
|
||||
lyrics.Wrap(eTrack, md.lyrics?.lyrics)
|
||||
}
|
||||
}
|
||||
|
||||
export async function OpenMetadata(eTrack) {
|
||||
let d = await device.Open(eTrack.handle).then(mediaTags)
|
||||
.catch(e => console.error(e))
|
||||
|
||||
return d && d.tags
|
||||
}
|
||||
|
||||
export function GenerateMetadata(eTrack) {
|
||||
return /((?<track>\d+)\s+)?((?<artist>.+)-\s*)?\s*(?<title>.+)\s*\.\w+/
|
||||
.exec(eTrack.handle.name)
|
||||
.groups
|
||||
}
|
||||
|
||||
const mediaTags = blob => new Promise((resolve, reject) => {
|
||||
jsmediatags.read(blob, {
|
||||
onSuccess(tags) {
|
||||
resolve(tags)
|
||||
},
|
||||
onError(err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export async function Duration(eTrack) {
|
||||
let f = await device.Open(eTrack.handle)
|
||||
let a = new Audio(URL.createObjectURL(f))
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
a.addEventListener('canplay', () => {
|
||||
eTrack.duration = a.duration
|
||||
resolve()
|
||||
})
|
||||
a.addEventListener('error', reject)
|
||||
|
||||
setTimeout(() => reject('Failed to get track duration for ', eTrack.handle.path), 4000)
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
@ -1,57 +0,0 @@
|
||||
export async function Wrap(eTrack, sLyrics) {
|
||||
eTrack.lyrics = new Map([ [ 0, sLyrics ] ])
|
||||
}
|
||||
|
||||
export async function Download(eTrack) {
|
||||
if(!eTrack.artist) {
|
||||
return
|
||||
}
|
||||
|
||||
let r = await createLrcLibRequest(eTrack)
|
||||
if(!r.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
let d = await r.json()
|
||||
let l = parseSyncedLyrics(d.syncedLyrics)
|
||||
if(l.size > 0) {
|
||||
eTrack.lyrics = l
|
||||
}
|
||||
}
|
||||
|
||||
export function createLrcLibRequest(eTrack) {
|
||||
let e = new URL('https://lrclib.net/api/get', window.location)
|
||||
|
||||
e.searchParams.set('track_name', eTrack.name)
|
||||
e.searchParams.set('artist_name', eTrack.artist)
|
||||
if(eTrack.album) {
|
||||
e.searchParams.set('album_name', eTrack.album)
|
||||
}
|
||||
// if(eTrack.duration != undefined) {
|
||||
// e.searchParams.set('duration', parseInt(eTrack.duration))
|
||||
// }
|
||||
|
||||
return fetch(e, {
|
||||
headers: {
|
||||
"Lrclib-Client": "hiss Audio Player"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const syncedLyricsRegex = /^\[(?<minutes>\d+):(?<seconds>\d+\.\d+)\] (?<line>.*)$/mg
|
||||
export function parseSyncedLyrics(sSyncedLyrics) {
|
||||
let out = new Map()
|
||||
let m
|
||||
|
||||
while ((m = syncedLyricsRegex.exec(sSyncedLyrics)) !== null) {
|
||||
if (m.index === syncedLyricsRegex.lastIndex) {
|
||||
regex.lastIndex++
|
||||
}
|
||||
|
||||
let s = parseInt(m.groups.minutes) * 60
|
||||
s += parseFloat(m.groups.seconds)
|
||||
out.set(s, m.groups.line)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
import * as cover from './cover.js'
|
||||
|
||||
export async function Get(eTrack) {
|
||||
return Narrow(await Query(eTrack), eTrack)
|
||||
}
|
||||
|
||||
export async function Query(eTrack) {
|
||||
let u = new URL('https://musicbrainz.org/ws/2/recording')
|
||||
|
||||
u.searchParams.set('query', LuceneQuery(eTrack))
|
||||
u.searchParams.set('fmt', 'json')
|
||||
|
||||
let r = await fetch(u, {
|
||||
headers: {
|
||||
"User-Agent": "hiss Audio Player"
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
if(!r || !r.ok) {
|
||||
return []
|
||||
}
|
||||
let d = await r.json()
|
||||
return d.recordings
|
||||
}
|
||||
|
||||
export function LuceneQuery(eTrack) {
|
||||
let query = {}
|
||||
|
||||
query.title = eTrack.name
|
||||
// query.dur = parseInt(eTrack.duration * 1000)
|
||||
if(eTrack.artist != null) {
|
||||
query.artist = eTrack.artist
|
||||
}
|
||||
if(eTrack.album != null) {
|
||||
query.release = eTrack.album
|
||||
}
|
||||
if(eTrack.trackNumber != null) {
|
||||
query.number = eTrack.trackNumber
|
||||
}
|
||||
|
||||
return Object.entries(query)
|
||||
.filter(([ ,v ]) => v != null)
|
||||
.map(([ k, v ]) => k + ':' + JSON.stringify(v) )
|
||||
.join(' AND ')
|
||||
}
|
||||
|
||||
export function Narrow(aRecordings, eTrack) {
|
||||
if(aRecordings.length == 0) {
|
||||
return null
|
||||
}
|
||||
if(aRecordings.length == 1) {
|
||||
return aRecordings[1]
|
||||
}
|
||||
|
||||
let s = aRecordings
|
||||
.map(r => {
|
||||
r.diff = Math.abs((r.length / 1000) - eTrack.duration)
|
||||
return r
|
||||
})
|
||||
.sort((a, b) => a.diff - b.diff)
|
||||
|
||||
return s[0]
|
||||
}
|
||||
|
||||
export function NeedsPolyfill(eTrack) {
|
||||
return eTrack.artist == null ||
|
||||
eTrack.coverId == null
|
||||
}
|
||||
|
||||
export async function Polyfill(eTrack, recording) {
|
||||
let artistCredit = recording['artist-credit']
|
||||
|
||||
if(eTrack.artist == null && artistCredit != null) {
|
||||
eTrack.artist = artistCredit[0].name
|
||||
}
|
||||
let release = MostRecentRelease(recording)
|
||||
if(eTrack.album == null) {
|
||||
eTrack.artist = release.title
|
||||
}
|
||||
if(eTrack.coverId == null) {
|
||||
eTrack.coverId = await cover.IndexRelease(release)
|
||||
}
|
||||
}
|
||||
|
||||
export function MostRecentRelease(recording) {
|
||||
let sr = recording.releases.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||
return sr[0]
|
||||
}
|
60
src/main.js
60
src/main.js
@ -1,60 +0,0 @@
|
||||
window.hiss = {
|
||||
state: {},
|
||||
view: {}
|
||||
}
|
||||
|
||||
import * as index from './index/index.js'
|
||||
import * as view from './view/view.js'
|
||||
import Store from 'store'
|
||||
import * as device from 'device'
|
||||
|
||||
export const db = await Store.Open('state', 'data')
|
||||
|
||||
export async function Start() {
|
||||
await device.Start()
|
||||
await openState()
|
||||
|
||||
await view.Init()
|
||||
}
|
||||
|
||||
export async function openState() {
|
||||
let s = await db.Get('state')
|
||||
|
||||
if(s) {
|
||||
Object.assign(hiss.state, s)
|
||||
} else {
|
||||
await State()
|
||||
await saveState()
|
||||
}
|
||||
|
||||
return s != undefined
|
||||
}
|
||||
|
||||
export async function saveState() {
|
||||
await db.Set('state', hiss.state)
|
||||
}
|
||||
|
||||
export async function Reset() {
|
||||
await db.Set('state', null)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
export async function State() {
|
||||
await index.State()
|
||||
await view.State()
|
||||
}
|
||||
|
||||
{
|
||||
Object.assign(window, {
|
||||
view,
|
||||
device,
|
||||
index,
|
||||
|
||||
Reset,
|
||||
Start,
|
||||
saveState,
|
||||
db
|
||||
})
|
||||
|
||||
Start()
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
import * as index from '../index/index.js'
|
||||
|
||||
export let Elements
|
||||
|
||||
export function Init() {
|
||||
Elements = new Array(hiss.indexSize)
|
||||
for(let entry of hiss.entries) {
|
||||
Elements[entry.id] = Render(entry)
|
||||
}
|
||||
}
|
||||
|
||||
export function Get(element) {
|
||||
return hiss.entries[element.dataset.id]
|
||||
}
|
||||
|
||||
export function Render(entry) {
|
||||
let li = document.createElement('li')
|
||||
|
||||
li.dataset.id = entry.id
|
||||
|
||||
if(index.IsTrack(entry)) {
|
||||
populateTrackEntry(entry, li)
|
||||
} else {
|
||||
populateListEntry(entry, li)
|
||||
}
|
||||
|
||||
return li
|
||||
}
|
||||
|
||||
const populateListEntry = (entry, li) => {
|
||||
let a = document.createElement('a')
|
||||
let header = document.createElement('header')
|
||||
|
||||
header.innerText = entry.name
|
||||
a.appendChild(header)
|
||||
li.appendChild(a)
|
||||
|
||||
li.dataset.actionSet = 'browser-list'
|
||||
}
|
||||
|
||||
const populateTrackEntry = (entry, li) => {
|
||||
// TODO: fix button problem on kaios
|
||||
let p = document.createElement('p')
|
||||
|
||||
let trackNumber = document.createElement('code')
|
||||
let title = document.createElement('b')
|
||||
let artist = document.createElement('cite')
|
||||
|
||||
trackNumber.innerText = formatTrackNumber(entry.trackNumber)
|
||||
title.innerText = entry.name
|
||||
artist.innerText = entry.artist
|
||||
|
||||
p.appendChild(title)
|
||||
p.appendChild(artist)
|
||||
|
||||
li.appendChild(trackNumber)
|
||||
li.appendChild(p)
|
||||
|
||||
li.dataset.actionSet = 'browser-track'
|
||||
}
|
||||
|
||||
const formatTrackNumber = trackNumber => {
|
||||
return trackNumber == Infinity ? '--' : trackNumber.toString().padStart(2, '0')
|
||||
}
|
@ -1,57 +0,0 @@
|
||||
import * as view from '../view.js'
|
||||
|
||||
export const order = [
|
||||
view.browser,
|
||||
view.queue
|
||||
]
|
||||
export let index = 0
|
||||
export let focus
|
||||
export let root
|
||||
|
||||
export function Init() {
|
||||
Target()
|
||||
}
|
||||
|
||||
export function Target() {
|
||||
if(root) {
|
||||
root.classList.remove('open')
|
||||
}
|
||||
({ root, focus } = order[index])
|
||||
root.classList.add('open')
|
||||
}
|
||||
|
||||
export function Next() {
|
||||
index++
|
||||
wrapIndex()
|
||||
Target()
|
||||
}
|
||||
|
||||
export function Prev() {
|
||||
index--
|
||||
wrapIndex()
|
||||
Target()
|
||||
}
|
||||
|
||||
export function wrapIndex() {
|
||||
if(index < 0) {
|
||||
index = order.length - 1
|
||||
} else if(index >= order.length) {
|
||||
index = 0
|
||||
}
|
||||
}
|
||||
|
||||
export function ScrollToFocusInList() {
|
||||
let c = Array.from(focus.parentNode.children)
|
||||
let i = c.indexOf(focus)
|
||||
c[Math.max(i - 4, 0)].scrollIntoView()
|
||||
}
|
||||
|
||||
export function ScrollFocusUp() {
|
||||
focus = view.PrevElement(focus)
|
||||
ScrollToFocusInList()
|
||||
}
|
||||
|
||||
export function ScrollFocusDown() {
|
||||
focus = view.NextElement(focus)
|
||||
ScrollToFocusInList()
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
import * as controller from '../view/controller.js'
|
||||
import * as browser from '../view/browser.js'
|
||||
import * as queue from '../view/queue.js'
|
||||
import * as entry from './entry.js'
|
||||
import * as panel from './panel/panel.js'
|
||||
|
||||
export async function State() {
|
||||
await browser.State()
|
||||
await queue.State()
|
||||
}
|
||||
|
||||
export function Init() {
|
||||
panel.Init()
|
||||
|
||||
browser.Init()
|
||||
queue.Init()
|
||||
|
||||
controller.Init()
|
||||
}
|
||||
|
||||
export function PrevElement(focus) {
|
||||
return focus.previousElementSibling ?? focus.parentNode.lastElementChild
|
||||
}
|
||||
|
||||
export function NextElement(focus) {
|
||||
return focus.nextElementSibling ?? focus.parentNode.firstElementChild
|
||||
}
|
||||
|
||||
export {
|
||||
browser,
|
||||
queue,
|
||||
entry,
|
||||
controller,
|
||||
panel
|
||||
}
|
@ -1,170 +0,0 @@
|
||||
import * as controller from "./controller.js"
|
||||
import * as view from "./view.js"
|
||||
import * as queue from "./queue.js"
|
||||
import * as index from "../index/index.js"
|
||||
|
||||
export async function State() {
|
||||
hiss.state.browser = {
|
||||
current: hiss.state.index,
|
||||
flatView: true,
|
||||
}
|
||||
}
|
||||
|
||||
export let Elements
|
||||
|
||||
export async function Init() {
|
||||
hiss.view.browser = {
|
||||
root: document.getElementById('browser'),
|
||||
menu: document.getElementById('browser-menu'),
|
||||
focus: null
|
||||
}
|
||||
|
||||
Elements = new Array(hiss.state.indexSize)
|
||||
for(let entry of hiss.state.entries) {
|
||||
Elements[entry.id] = renderEntry(entry)
|
||||
}
|
||||
|
||||
Render()
|
||||
|
||||
{
|
||||
let a = hiss.view.actions['browser-list'] = {}
|
||||
|
||||
a['Enter'] = {
|
||||
up(focus) {
|
||||
Cd(Entry(hiss.view.browser.focus))
|
||||
}
|
||||
}
|
||||
a[' '] = {
|
||||
down() {
|
||||
view.FocusNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let a = hiss.view.actions['browser-track'] = {}
|
||||
|
||||
a['Enter'] = a[' '] = {
|
||||
down(focus) {
|
||||
queue.Add(Entry(hiss.view.browser.focus))
|
||||
view.FocusNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let a = hiss.view.actions['browser'] = {}
|
||||
Object.assign(a, hiss.view.actions.menu)
|
||||
|
||||
a['Backspace'] = a['Escape'] = {
|
||||
up: Back
|
||||
}
|
||||
|
||||
a['r'] = a['4'] = {
|
||||
up: ToggleFlatView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function Entry(element) {
|
||||
return hiss.state.entries[element.dataset.id]
|
||||
}
|
||||
|
||||
export function Render() {
|
||||
let menu = hiss.view.browser.menu
|
||||
|
||||
ReorganizeList(
|
||||
menu,
|
||||
Entries()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(e => Elements[e.id])
|
||||
)
|
||||
|
||||
view.Focus(menu.querySelector('.focus') ?? menu.children[0] ?? menu, hiss.view.browser)
|
||||
view.ScrollInListTo(hiss.view.browser.focus)
|
||||
}
|
||||
|
||||
export function Open() {
|
||||
// May be unnecessary once each menu has its own scrolling rules
|
||||
view.ScrollInListTo(hiss.view.browser.focus)
|
||||
}
|
||||
|
||||
export function ReorganizeList(eList, eItems) {
|
||||
while (eList.firstChild) {
|
||||
eList.removeChild(eList.lastChild)
|
||||
}
|
||||
for(let child of eItems) {
|
||||
eList.appendChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
export function renderEntry(entry) {
|
||||
let li = document.createElement('li')
|
||||
|
||||
li.dataset.id = entry.id
|
||||
|
||||
if(index.IsTrack(entry)) {
|
||||
populateTrackEntry(entry, li)
|
||||
} else {
|
||||
populateListEntry(entry, li)
|
||||
}
|
||||
|
||||
return li
|
||||
}
|
||||
|
||||
export function populateListEntry(entry, li) {
|
||||
let a = document.createElement('a')
|
||||
let header = document.createElement('header')
|
||||
|
||||
header.innerText = entry.name
|
||||
a.appendChild(header)
|
||||
li.appendChild(a)
|
||||
|
||||
li.dataset.actionSet = 'browser-list'
|
||||
}
|
||||
|
||||
export function populateTrackEntry(eTrack, li) {
|
||||
// TODO: fix button problem on kaios
|
||||
let aside = document.createElement('aside')
|
||||
let trackNumber = document.createElement('code')
|
||||
|
||||
view.populateTrackInfo(eTrack, aside)
|
||||
trackNumber.innerText = formatTrackNumber(eTrack.trackNumber)
|
||||
|
||||
li.appendChild(trackNumber)
|
||||
li.appendChild(aside)
|
||||
|
||||
li.dataset.actionSet = 'browser-track'
|
||||
}
|
||||
|
||||
export function formatTrackNumber(trackNumber) {
|
||||
return trackNumber == Infinity ? '--' : trackNumber.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
export function Entries() {
|
||||
return hiss.state.browser.flatView ?
|
||||
hiss.state.browser.current.tracks.concat(Lists()) :
|
||||
hiss.state.browser.current.entries
|
||||
}
|
||||
|
||||
export function Lists() {
|
||||
return hiss.state.browser.current.entries.filter(e => !index.IsTrack(e))
|
||||
}
|
||||
|
||||
export function Cd(entry) {
|
||||
hiss.state.browser.current = entry
|
||||
Render()
|
||||
}
|
||||
|
||||
export function Back() {
|
||||
let i = hiss.state.browser.current.id
|
||||
let p = hiss.state.browser.current.parent
|
||||
if(p) {
|
||||
Cd(p)
|
||||
view.Focus(hiss.view.browser.menu.querySelector(`[data-id="${i}"]`))
|
||||
view.ScrollInListTo(hiss.view.browser.focus)
|
||||
}
|
||||
}
|
||||
|
||||
export function ToggleFlatView() {
|
||||
hiss.state.browser.flatView = !hiss.state.browser.flatView
|
||||
Render()
|
||||
}
|
@ -1,103 +0,0 @@
|
||||
const State = window.State ??= {}
|
||||
const View = window.View ??= {}
|
||||
|
||||
import * as view from './view.js'
|
||||
|
||||
export function Init() {
|
||||
hiss.view.actions = {}
|
||||
|
||||
{
|
||||
let _ = hiss.view.actions.menu = {}
|
||||
|
||||
_['ArrowUp'] = {
|
||||
down() {
|
||||
view.FocusPrev()
|
||||
}
|
||||
}
|
||||
_['ArrowDown'] = {
|
||||
down() {
|
||||
view.FocusNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let _ = hiss.view.actions.main = {}
|
||||
|
||||
_['ArrowRight'] = _['ArrowLeft'] = {
|
||||
up() {
|
||||
view.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keyup', (e) => OnKey(e, ActionUp))
|
||||
window.addEventListener('keydown', (e) => OnKey(e, ActionDown))
|
||||
}
|
||||
|
||||
export function GetAction(key, element = view.Panel().focus) {
|
||||
let actionSet = element.dataset.actionSet && hiss.view.actions[element.dataset.actionSet]
|
||||
if(actionSet && actionSet[key]) {
|
||||
return actionSet[key]
|
||||
}
|
||||
let p = element.parentNode.closest('*[data-action-set]')
|
||||
if(p) {
|
||||
return GetAction(key, p)
|
||||
}
|
||||
}
|
||||
|
||||
export function OnKey(event, callback) {
|
||||
let a = GetAction(event.key)
|
||||
if(a == null) return
|
||||
|
||||
let failed = callback(a.shift && event.shiftKey ? a.shift : a)
|
||||
if(!failed) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
export function ActionDown(action) {
|
||||
if(action.holdTimeout != null) {
|
||||
return true
|
||||
}
|
||||
|
||||
if(action.down) {
|
||||
return action.down(focus)
|
||||
}
|
||||
|
||||
if(action.hold) {
|
||||
Hold(action, element)
|
||||
} else if(action.press) {
|
||||
return action.press(focus)
|
||||
}
|
||||
}
|
||||
|
||||
export function ActionUp(action) {
|
||||
if(action.holdTimeout != null) {
|
||||
clearTimeout(action.holdTimeout)
|
||||
action.holdTimeout = null
|
||||
}
|
||||
|
||||
if(action.up) {
|
||||
return action.up(focus)
|
||||
}
|
||||
|
||||
if(action.heldCount > 0) {
|
||||
return action.press(focus)
|
||||
}
|
||||
}
|
||||
|
||||
export function Hold(aAction) {
|
||||
aAction.heldCount = holdIntervals
|
||||
createHoldTimeout(aAction)
|
||||
}
|
||||
|
||||
export function createHoldTimeout(aAction) {
|
||||
aAction.holdTimeout = setTimeout(() => {
|
||||
aAction.heldCount--
|
||||
if(aAction.heldCount == 0) {
|
||||
aaction.hold(focus)
|
||||
} else {
|
||||
createHoldTimeout(aAction)
|
||||
}
|
||||
}, holdIntervalLength)
|
||||
}
|
@ -1,70 +0,0 @@
|
||||
import * as index from "../index/index.js"
|
||||
|
||||
export function State() {
|
||||
hiss.state.queue = []
|
||||
|
||||
hiss.state.playing = {
|
||||
currentTime: 0
|
||||
}
|
||||
hiss.state.playing.track = {
|
||||
name: 'Nothing Playing',
|
||||
artist: '-',
|
||||
duration: Infinity,
|
||||
coverId: null
|
||||
}
|
||||
}
|
||||
|
||||
export function Init() {
|
||||
hiss.view.player = {
|
||||
root: document.getElementById('player'),
|
||||
cover: document.getElementById('track-cover'),
|
||||
title: document.getElementById('track-title'),
|
||||
artist: document.getElementById('track-artist'),
|
||||
duration: document.getElementById('track-duration'),
|
||||
currentTime: document.getElementById('track-timestamp')
|
||||
}
|
||||
hiss.view.player.focus = hiss.view.player.root
|
||||
}
|
||||
|
||||
export async function Render() {
|
||||
await renderInfo()
|
||||
}
|
||||
|
||||
export function Open() {
|
||||
Render()
|
||||
}
|
||||
|
||||
export async function renderInfo() {
|
||||
let track = hiss.state.playing.track
|
||||
|
||||
hiss.view.player.cover.src = await index.cover.Url(track.coverId) ?? ''
|
||||
hiss.view.player.duration.innerText = formatTime(track.duration)
|
||||
hiss.view.player.title.innerText = track.name
|
||||
hiss.view.player.artist.innerText = track.artist
|
||||
}
|
||||
|
||||
export const formatTime = currentTime => {
|
||||
let minutes = Math.floor(currentTime / 60).toString()
|
||||
let seconds = Math.floor(currentTime % 60).toString()
|
||||
|
||||
return minutes.padStart(2, '0') + ':' + seconds.padStart(2, '0')
|
||||
}
|
||||
|
||||
export function Play(eTrack) {
|
||||
|
||||
}
|
||||
|
||||
export const Statuses = {
|
||||
PLAYING: 0,
|
||||
PAUSED: 1,
|
||||
ENDED: 2
|
||||
}
|
||||
export function Status() {
|
||||
if(hiss.view.playback.paused) {
|
||||
return Statuses.PAUSED
|
||||
}
|
||||
}
|
||||
|
||||
export function Queue(eTrack) {
|
||||
hiss.state.queue.push(eTrack)
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
import * as controller from "./controller.js"
|
||||
import * as view from "./view.js"
|
||||
|
||||
export function Init() {
|
||||
hiss.view.queue = {
|
||||
root: document.getElementById('queue'),
|
||||
menu: document.getElementById('queue-menu'),
|
||||
focus: null
|
||||
}
|
||||
|
||||
Render()
|
||||
}
|
||||
|
||||
export function Render() {
|
||||
let menu = hiss.view.queue.menu
|
||||
|
||||
let len = 0
|
||||
while (menu.firstChild) {
|
||||
menu.removeChild(menu.lastChild)
|
||||
}
|
||||
for(let i = 0; i < hiss.state.queue.length; i++) {
|
||||
let t = hiss.state.queue[i]
|
||||
len += t.duration
|
||||
menu.appendChild(renderTrack(t, i, len))
|
||||
}
|
||||
|
||||
view.Focus(menu.children[0] ?? menu, hiss.view.queue)
|
||||
}
|
||||
|
||||
export function Open() {
|
||||
Render()
|
||||
}
|
||||
|
||||
export function renderTrack(eTrack, iIndex, fLength) {
|
||||
let li = document.createElement('li')
|
||||
let aside = document.createElement('aside')
|
||||
let timestamp = document.createElement('code')
|
||||
|
||||
view.populateTrackInfo(eTrack, aside)
|
||||
timestamp.innerText = view.FormatTime(parseInt(fLength))
|
||||
|
||||
li.appendChild(timestamp)
|
||||
li.appendChild(aside)
|
||||
|
||||
li.dataset.actionSet = 'queue-track'
|
||||
li.dataset.index = iIndex
|
||||
|
||||
return li
|
||||
}
|
118
src/view/view.js
118
src/view/view.js
@ -1,118 +0,0 @@
|
||||
import * as controller from './controller.js'
|
||||
import * as player from './player.js'
|
||||
import * as browser from './browser.js'
|
||||
import * as queue from './queue.js'
|
||||
|
||||
export {
|
||||
player,
|
||||
browser,
|
||||
queue
|
||||
}
|
||||
|
||||
export function State() {
|
||||
player.State()
|
||||
browser.State()
|
||||
queue.State()
|
||||
}
|
||||
|
||||
export async function Init() {
|
||||
await controller.Init()
|
||||
|
||||
await player.Init()
|
||||
await browser.Init()
|
||||
await queue.Init()
|
||||
|
||||
hiss.view.panels = [
|
||||
hiss.view.player,
|
||||
hiss.view.browser,
|
||||
hiss.view.queue
|
||||
]
|
||||
hiss.view.modules = [
|
||||
player,
|
||||
browser,
|
||||
queue
|
||||
]
|
||||
hiss.view.index = 1
|
||||
|
||||
Display()
|
||||
}
|
||||
|
||||
export function Display() {
|
||||
let module = hiss.view.modules[hiss.view.index]
|
||||
document.body.querySelector('section.open')?.classList.remove('open')
|
||||
Panel().root.classList.add('open')
|
||||
module.Open()
|
||||
}
|
||||
|
||||
export function Next() {
|
||||
hiss.view.index = wrapValue(hiss.view.index - 1, 0, hiss.view.panels.length)
|
||||
Display()
|
||||
}
|
||||
|
||||
export function Prev() {
|
||||
hiss.view.index = wrapValue(hiss.view.index + 1, 0, hiss.view.panels.length)
|
||||
Display()
|
||||
}
|
||||
|
||||
export function wrapValue(value, min, max) {
|
||||
if(value < min) {
|
||||
value = max - 1
|
||||
} else if(value >= max) {
|
||||
value = min
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function populateTrackInfo(eTrack, ele) {
|
||||
let title = document.createElement('b')
|
||||
let artist = document.createElement('cite')
|
||||
|
||||
title.innerText = eTrack.name
|
||||
artist.innerText = eTrack.artist ?? "Unknown Artist"
|
||||
|
||||
ele.appendChild(title)
|
||||
ele.appendChild(artist)
|
||||
}
|
||||
|
||||
export function FormatTime(iSeconds) {
|
||||
let minutes = Math.floor(iSeconds / 60).toString()
|
||||
let seconds = Math.floor(iSeconds % 60).toString()
|
||||
|
||||
return minutes.padStart(2, '0') + ':' + seconds.padStart(2, '0')
|
||||
}
|
||||
|
||||
export function ScrollInListTo(target) {
|
||||
let c = Array.from(target.parentNode.children)
|
||||
let i = c.indexOf(target)
|
||||
c[Math.max(i - 4, 0)].scrollIntoView()
|
||||
}
|
||||
|
||||
export function PrevElement(element) {
|
||||
return element.previousElementSibling ?? element.parentNode.lastElementChild
|
||||
}
|
||||
|
||||
export function NextElement(element) {
|
||||
return element.nextElementSibling ?? element.parentNode.firstElementChild
|
||||
}
|
||||
|
||||
export function FocusPrev(element = Panel().focus) {
|
||||
let p = PrevElement(element)
|
||||
ScrollInListTo(p)
|
||||
Focus(p)
|
||||
}
|
||||
|
||||
export function FocusNext(element = Panel().focus) {
|
||||
let n = NextElement(element)
|
||||
ScrollInListTo(n)
|
||||
Focus(n)
|
||||
}
|
||||
|
||||
export function Panel() {
|
||||
return hiss.view.panels[hiss.view.index]
|
||||
}
|
||||
|
||||
export function Focus(focus, context = Panel()) {
|
||||
context.focus?.classList.remove('focus')
|
||||
context.focus = focus
|
||||
focus.classList.add('focus')
|
||||
}
|
166
style.css
Normal file
166
style.css
Normal file
@ -0,0 +1,166 @@
|
||||
:root {
|
||||
--vii: #000;
|
||||
--vi: #444;
|
||||
--v: #0bb;
|
||||
--iv: #aaa;
|
||||
--iiv: #fff;
|
||||
--a: #f33;
|
||||
}
|
||||
|
||||
button {
|
||||
color: var(--iiv);
|
||||
background: unset;
|
||||
border: 0;
|
||||
}
|
||||
button:hover, button:focus {
|
||||
color: var(--a)
|
||||
}
|
||||
button:active {
|
||||
color: var(--v)
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--vii);
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
background: var(--vii);
|
||||
color: var(--iiv);
|
||||
|
||||
font-family: sans-serif;
|
||||
font-size: 15px;
|
||||
|
||||
/* width: 320px; */
|
||||
/* height: 480px; */
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: relative;
|
||||
}
|
||||
header, nav {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
}
|
||||
header h3, header p {
|
||||
margin-block: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
nav button {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
nav, nav button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
nav svg {
|
||||
stroke-width: 0;
|
||||
stroke-linecap: square;
|
||||
stroke-linejoin: square;
|
||||
stroke: currentcolor;
|
||||
fill: currentcolor;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
#playpause {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid currentcolor;
|
||||
}
|
||||
|
||||
#timestamp, #track-length {
|
||||
position: absolute;
|
||||
padding: 5px;
|
||||
font-size: 13px;
|
||||
bottom: 0;
|
||||
}
|
||||
#timestamp {
|
||||
color: var(--iiv);
|
||||
left: 0;
|
||||
}
|
||||
#track-length {
|
||||
color: var(--iv);
|
||||
right: 0;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: 100%;
|
||||
block-size: 3px;
|
||||
appearance: none;
|
||||
}
|
||||
progress::-webkit-progress-bar {
|
||||
background-color: var(--vi);
|
||||
}
|
||||
progress::-webkit-progress-value {
|
||||
background-color: var(--iiv);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 5px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
ul li {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
ul button {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ul .titled-track {
|
||||
border-bottom: 1px dotted var(--iv);
|
||||
} */
|
||||
|
||||
ul code {
|
||||
margin-right: 5px;
|
||||
color: var(--v)
|
||||
}
|
||||
|
||||
ul cite {
|
||||
float: right;
|
||||
}
|
||||
|
||||
ul .directory:after {
|
||||
float: right;
|
||||
content: ">";
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 5px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
#left-action {
|
||||
margin-right: auto;
|
||||
}
|
||||
#right-action {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
background: none;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--iv);
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
task/setup-build "browser"
|
@ -1,10 +0,0 @@
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"device": "./device/browser.js",
|
||||
"store": "./lib/store.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="./node_modules/jsmediatags/dist/jsmediatags.min.js"></script>
|
||||
<script type="module" src="src/main.js"></script>
|
@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
task/inject "targets/kaios.html"
|
||||
rm -rf build/*
|
||||
cp -r index.html ./targets/manifest.webapp assets/ build/
|
||||
# ./node_modules/.bin/babel device --out-dir build/device
|
||||
# ./node_modules/.bin/babel src --out-dir build/src
|
||||
# ./node_modules/.bin/babel lib --out-dir build/lib
|
||||
cp node_modules/jsmediatags/dist/jsmediatags.min.js build/
|
||||
node_modules/rollup/dist/bin/rollup -c ./rollup.config.js
|
@ -1,2 +0,0 @@
|
||||
<script src="./jsmediatags.min.js"></script>
|
||||
<script src="./main.js"></script>
|
@ -1,45 +0,0 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"name": "audioplayer",
|
||||
"description": "Simple example of a to-do list",
|
||||
"type": "privileged",
|
||||
"fullscreen": true,
|
||||
"launch_path": "/index.html",
|
||||
"icons": {
|
||||
"56": "/assets/icons/kaios_56.png",
|
||||
"112": "/assets/icons/kaios_112.png"
|
||||
},
|
||||
"developer": {
|
||||
"name": "Dakedres",
|
||||
"url": "https://dakedr.es"
|
||||
},
|
||||
"locales": {
|
||||
"en-US": {
|
||||
"name": "Audioplayer",
|
||||
"subtitle": "Simple example of a to-do list",
|
||||
"description": "Simple example of a to-do list"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"device-storage:sdcard": {
|
||||
"description": "Read/Write from/to sd-card",
|
||||
"access": "readwrite"
|
||||
},
|
||||
|
||||
"device-storage:music": {
|
||||
"description": "Read/Write from/to intern storage",
|
||||
"access": "readwrite"
|
||||
},
|
||||
|
||||
"audio-channel-normal" : {
|
||||
"description" : "Needed to play this app's audio content on the normal channel"
|
||||
},
|
||||
"audio-channel-content" : {
|
||||
"description" : "Needed to play this app's audio content on the content channel"
|
||||
},
|
||||
|
||||
"volumemanager":{},
|
||||
"browser": {}
|
||||
},
|
||||
"default_locale": "en-US"
|
||||
}
|
13
targets/nw
13
targets/nw
@ -1,13 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
util/setup-build "nw"
|
||||
wget -c "https://dl.nwjs.io/v0.93.0/nwjs-v0.93.0-linux-x64.tar.gz" -O - \
|
||||
| tar -xz -C build/ --strip-components=1
|
||||
printf %s\\n '#!/bin/sh
|
||||
|
||||
tc_path=$(readlink -f "$0")
|
||||
tc_dir=$(dirname "$tc_path")
|
||||
|
||||
"${tc_dir}/nw" "${tc_dir}"
|
||||
' > build/tc
|
||||
chmod +x build/tc
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
"imports": {
|
||||
"device": "./device/nw.js"
|
||||
}
|
||||
}
|
26
task/inject
26
task/inject
@ -1,26 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
injection="$1"
|
||||
tmp="$(mktemp)"
|
||||
|
||||
awk '
|
||||
BEGIN {
|
||||
s="<!-- start injection -->"
|
||||
e="<!-- end injection -->"
|
||||
}
|
||||
|
||||
FILENAME != "index.html" {
|
||||
d=d $0 "\n"
|
||||
next
|
||||
}
|
||||
$0 == s || $0 == e { print }
|
||||
$0 == s,$0 == e {
|
||||
if(d) {
|
||||
printf d;
|
||||
d = ""
|
||||
}
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$injection" index.html > "$tmp" \
|
||||
&& mv "$tmp" index.html
|
@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
name="$1"
|
||||
|
||||
task/inject "targets/${name}.html"
|
||||
rm -rf build/*
|
||||
|
||||
cp -r src/ build/
|
||||
cp -r assets/ build/
|
||||
cp -r lib/ build/
|
||||
|
||||
cp index.html package.json debug.js build/
|
||||
|
||||
mkdir -p build/device
|
||||
cp "device/${name}.js" build/device
|
Loading…
x
Reference in New Issue
Block a user