mmmm everything

This commit is contained in:
Dakedres 2025-02-04 14:21:03 -07:00
parent b9af38f49d
commit 55795c7cdb
36 changed files with 4544 additions and 556 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
build/
node_modules/
*.temp

View File

@ -2,20 +2,20 @@ import Store from 'store'
const noWorkingDirWarning = "Please open a directory to allow file saving."
export let cwd
export let root
export let store
export async function Start() {
store = await Store.Open('device', 'handles')
cwd = await store.Get('directory')
root = await store.Get('directory')
}
export async function cd() {
cwd = await window.showDirectoryPicker()
await store.Set('directory', cwd)
root = await window.showDirectoryPicker()
await store.Set('directory', root)
}
export async function Entries(handle = cwd) {
export async function Entries(handle = root) {
let pool = new Map()
for await (let [ , e ] of handle.entries()) {
@ -26,11 +26,11 @@ export async function Entries(handle = cwd) {
}
export async function Create(sPath) {
return cwd.getFileHandle(sPath, { create: true })
return root.getFileHandle(sPath, { create: true })
}
export async function Open(hFile) {
if(!cwd) {
if(!root) {
return new Blob([ noWorkingDirWarning ], { type: "text/plain" })
}
@ -38,7 +38,7 @@ export async function Open(hFile) {
}
export async function Write(hFile, sContent) {
if(!cwd) {
if(!root) {
throw new Error("Unable to save file: no working directory")
}

View File

@ -1,4 +1,5 @@
export const sdcard = navigator.getDeviceStorage('sdcard')
export let root
export function promisify(request) {
return new Promise((resolve, reject) => {
@ -26,7 +27,7 @@ function handle(sPath, sName) {
function resolveHandle(handle) {
let cs = handle.path.split('/')
let f = filesystemIndex
let f = root
for(let c of cs) {
f = f.entries[c]
@ -38,8 +39,6 @@ function resolveHandle(handle) {
return f
}
export let filesystemIndex
export function Start() {
}
@ -50,17 +49,17 @@ export const createIndex = () => {
const filesystemIndexHandler = (resolve, reject) => {
let cursor = sdcard.enumerate();
filesystemIndex = {}
root = {}
cursor.onerror = function() {
reject(cursor.error)
}
cursor.onsuccess = function() {
if(!this.result) {
return resolve(filesystemIndex)
return resolve(root)
}
let cs = this.result.name.split('/').slice(1)
let f = filesystemIndex
let f = root
let c
for(let i = 0; i < cs.length; i++) {
c = cs[i]
@ -78,10 +77,10 @@ const filesystemIndexHandler = (resolve, reject) => {
}
export async function Entries(handle) {
if(!filesystemIndex)
if(!root)
await createIndex()
let pool = new Map()
let t = handle == null ? filesystemIndex : handle
let t = handle == null ? root : handle
for(let n in t.entries ?? {}) {
let h = t.entries[n]
pool.set(h.name, h)

View File

@ -1,6 +1,6 @@
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hiss</title>
<!-- start injection -->
<script type="importmap">
{
@ -11,106 +11,93 @@
}
</script>
<script src="./node_modules/jsmediatags/dist/jsmediatags.min.js"></script>
<script type="module" src="src/index.js"></script>
<script type="module" src="src/main.js"></script>
<!-- end injection -->
<script type="module" src="src/index.js"></script>
<link rel="stylesheet" href="assets/main.css"></link>
<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>
</head>
<body>
<!-- <section class="controls">
<main data-action-set="main">
<header>
<section id="browser">
<h3 id="track-title">Bocce</h3>
<p id="track-artist">Patricia Taxxon</p>
<header>Browser</header>
<menu data-action-set="browser" id="browser-menu"></menu>
</header>
<nav>
</section>
<section id="queue">
<button id="prev">
<header>Queue</header>
<menu data-action-set="menu" id="queue-menu"></menu>
<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">
</section>
<section id="player">
<img id="track-cover">
<cite id="track-title">No Track Selected</cite>
<cite id="track-artist">Unknown Artist</cite>
<cite id="track-title"></cite>
<cite id="track-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>&mdash; Loading &mdash;</header>
<small id="loading-status"></small>
<code id="track-current-time">--:--</code>
<code id="track-duration">--:--</code>
</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>

108
old.index.html Normal file
View File

@ -0,0 +1,108 @@
<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>&mdash; Loading &mdash;</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>

View File

@ -1,10 +1,5 @@
import * as main from './main.js'
import * as browser from './browser.js'
import * as queue from './queue.js'
import * as entry from './entry.js'
import * as mode from './mode.js'
import * as player from './player.js'
import * as input from './input.js'
import * as device from 'device'
window.device = device

0
old.src/index/cover.js Normal file
View File

110
old.src/main.js Normal file
View File

@ -0,0 +1,110 @@
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()
}

View File

@ -3,15 +3,15 @@
// - name
// - length
import List from "./list.js"
import * as entry from "./entry.js"
import * as mode from "./mode.js"
import * as device from "device"
import List from "./List.js"
import * as panels from "./panels.js"
const app = window.app ??= {}
const view = window.view ??= {}
export let list
export let list = new List(view.browser = {
element: document.getElementById('browser')
})
export const Init = async () => {
app.browser = {}
@ -25,9 +25,6 @@ export const Init = async () => {
}
export const View = async () => {
list = new List(view.browser = {
element: document.getElementById('browser')
})
Render()
}
@ -58,7 +55,7 @@ export const recurseView = (eaEntries = app.browser.current.entries, bIncludeDir
}
export const Open = () => {
mode.Display('browser')
panels.Display('browser')
}
export const Back = () => {

19
old.src/view/index.js Normal file
View File

@ -0,0 +1,19 @@
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')
}

View File

@ -7,17 +7,11 @@ 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'
export function Init() {
}
export function View() {
view.c
{
window.addEventListener('keydown', OnKeydown)
window.addEventListener('keyup', OnKeyup)
Render()
window.addEventListener('keyup', OnKeyup)
}
export function Render() {
@ -27,6 +21,13 @@ export function Render() {
export function Actions() {
let a = view.actions = {}
a['EndCall'] = {
name: 'Close Hiss',
up() {
main.saveState().then(() => window.close())
}
}
a['ArrowRight'] = {
name: 'Next Panel',
up() {
@ -40,7 +41,7 @@ export function Actions() {
}
}
a['0'] = {
}
a['c'] = a.pausePlay = {

View File

@ -1,9 +1,9 @@
const view = window.view ??= {}
const app = window.app ??= {}
import * as mode from "./mode.js"
import * as mode from "./panels.js"
import * as queue from "./queue.js"
import * as entry from "./entry.js"
import * as entry from "../entry.js"
export const states = {
EMPTY: 0,
@ -32,32 +32,6 @@ export const View = async () => {
}
}
export const Render = async () => {
marqueeableText(view.player.title, app.player.current.name)
marqueeableText(view.player.artist, app.player.current.artist)
renderTimestamp()
renderCover()
}
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 Open = () => {
mode.Display('player')
@ -103,7 +77,7 @@ export const OnKeydown = (event) => {
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
@ -120,7 +94,7 @@ export const Switch = (aTrack, eTrack) => {
if(State() === states.PLAYING) {
view.playback.pause()
}
view.playback = aTrack
app.player.current = eTrack
attach()
@ -129,29 +103,20 @@ export const Switch = (aTrack, eTrack) => {
export const attach = () => {
let a = view.playback
a.addEventListener('playing', onStart)
a.addEventListener('play', onPlay)
a.addEventListener('pause', onPause)
a.addEventListener('timeupdate', onTimeUpdate)
a.addEventListener('ended', onEnded)
storeTime()
}
export const onStart = () => {
storeTime()
renderDuration()
onPlay()
onTimeUpdate()
}
export const onPlay = () => {
}
export const onPause = () => {
}
export const onTimeUpdate = () => {
let s = parseInt(view.playback.currentTime)
if(s > app.player.currentTime) {
@ -174,17 +139,47 @@ export const formatTime = currentTime => {
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 onEnded = () => {
view.playback = null
Next()
}
export const renderTimestamp = (iTimestamp = app.player.currentTime) => {
view.player.timestamp.innerText = formatTime(iTimestamp)
}
@ -193,6 +188,14 @@ 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()
@ -220,10 +223,4 @@ export const State = () => {
} else {
return view.playback.paused ? states.PAUSED : states.PLAYING
}
}
export const renderCover = async () => {
view.player.cover.src = app.player.current.coverId ?
URL.createObjectURL(await entry.GetCover(app.player.current)):
''
}

View File

@ -1,6 +1,5 @@
import List from "./list.js"
import * as mode from "./mode.js"
import List from "./List.js"
import * as mode from "./panels.js"
import * as player from "./player.js"
const app = window.app ??= {}

2899
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@ const map = {
}
const config = {
input: 'src/index.js',
input: 'src/main.js',
output: {
dir: 'build',
format: 'iife'

1
src/in
View File

@ -1 +0,0 @@
1

54
src/index/cover.js Normal file
View File

@ -0,0 +1,54 @@
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
}

153
src/index/index.js Normal file
View File

@ -0,0 +1,153 @@
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)
}

57
src/index/lyrics.js Normal file
View File

@ -0,0 +1,57 @@
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
}

89
src/index/musicbrainz.js Normal file
View File

@ -0,0 +1,89 @@
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]
}

View File

@ -1,117 +1,60 @@
const app = window.app ??= {}
const view = window.view ??= {}
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'
import * as browser from './browser.js'
import * as entry from './entry.js'
import * as mode from './mode.js'
import * as input from './input.js'
export let store
export let saveTimeout
export const db = await Store.Open('state', 'data')
export const Start = async () => {
store = await Store.Open('app', 'data')
export async function Start() {
await device.Start()
await entry.Start()
let success = await loadState()
await openState()
if(!success) {
await Init()
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()
}
window.addEventListener('visibilitychange', async event => {
if(document.visibilityState !== 'visible') {
await saveState()
}
})
await View()
view.loading.remove()
return s != undefined
}
export const Init = async () => {
mode.Init()
await entry.Init()
await browser.Init()
await queue.Init()
await player.Init()
export async function saveState() {
await db.Set('state', hiss.state)
}
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)
export async function Reset() {
await db.Set('state', null)
window.location.reload()
}
export const OnKeydown = (event) => {
switch(event.key) {
case 'EndCall':
saveState().then(() => window.close())
break
export async function State() {
await index.State()
await view.State()
}
case '1':
mode.Set(mode.states.PLAYER)
break
{
Object.assign(window, {
view,
device,
index,
Reset,
Start,
saveState,
db
})
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()
Start()
}

64
src/oldview/entry.js Normal file
View File

@ -0,0 +1,64 @@
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')
}

View File

@ -0,0 +1,57 @@
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()
}

35
src/oldview/view.js Normal file
View File

@ -0,0 +1,35 @@
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
}

170
src/view/browser.js Normal file
View File

@ -0,0 +1,170 @@
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()
}

103
src/view/controller.js Normal file
View File

@ -0,0 +1,103 @@
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)
}

70
src/view/player.js Normal file
View File

@ -0,0 +1,70 @@
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)
}

49
src/view/queue.js Normal file
View File

@ -0,0 +1,49 @@
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 Normal file
View File

@ -0,0 +1,118 @@
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')
}

View File

@ -7,4 +7,4 @@
}
</script>
<script src="./node_modules/jsmediatags/dist/jsmediatags.min.js"></script>
<script type="module" src="src/index.js"></script>
<script type="module" src="src/main.js"></script>

View File

@ -1,2 +1,2 @@
<script src="./jsmediatags.min.js"></script>
<script src="./index.js"></script>
<script src="./main.js"></script>

463
yarn.lock

File diff suppressed because it is too large Load Diff