hiss/index.js
2024-11-22 23:29:59 -07:00

526 lines
10 KiB
JavaScript

let view = {}
let extensions = {
audio: [ "mp3", "wav", "ogg" ]
}
let entryTypes = {
DIRECTORY: 0,
AUDIO: 1
}
let list = {
focused: false,
directory: null,
entries: []
}
let player = {
audio: null,
queue: [],
history: []
}
const main = async () => {
view.list = document.getElementById('list')
view.trackTitle = document.getElementById('track-title')
view.trackArtist = document.getElementById('track-artist')
view.playbackProgress = document.getElementById('playback-progress')
view.timestamp = document.getElementById('timestamp')
view.trackLength = document.getElementById('track-length')
view.prev = document.getElementById('prev')
view.playpause = document.getElementById('playpause')
view.next = document.getElementById('next')
view.pauseIcon = document.getElementById('pause-icon')
view.playIcon = document.getElementById('play-icon')
view.prev.addEventListener('click', previousTrack)
view.next.addEventListener('click', nextTrack)
view.playpause.addEventListener('click', togglePlay)
window.addEventListener('keydown', onKeydown)
window.addEventListener('mousemove', onMouseMove)
let files = await IDBStore.get('files') ?? {
entries: [],
root: true
}
displayPaused()
openDirectory(files)
grabFocus()
}
/* Interface */
const renderList = (entries, isRoot) => {
let focused = isListFocused()
while (view.list.firstChild) {
view.list.removeChild(view.list.lastChild)
}
list.entries = []
let audioEntries = []
let index = 0
const appendEntry = entry => {
view.list.appendChild(renderEntry(entry, index))
list.entries.push(entry)
index++
}
for(let entry of entries) {
if(entry.type == entryTypes.DIRECTORY) {
appendEntry(entry)
} else {
audioEntries.push(entry)
}
}
for(let entry of orderTracks(audioEntries)) {
appendEntry(entry)
}
if(focused) {
grabFocus()
}
}
const orderTracks = (entries) => {
return entries.sort((a, b) => a.tags.trackNumber - b.tags.trackNumber)
}
const renderEntry = (entry, index) => {
let button = document.createElement('button')
let item = document.createElement('li')
if(entry.type == entryTypes.DIRECTORY) {
populateDirectoryEntry(entry, button)
} else {
populateTrackEntry(entry, button)
}
item.appendChild(button)
button.dataset.index = index
button.addEventListener('click', () => onClickListButton(button))
return item
}
const populateDirectoryEntry = (entry, button) => {
button.classList.add('directory')
button.innerText = entry.name + '/'
}
const populateTrackEntry = (entry, button) => {
if(!entry.tags.title) {
button.innerText = entry.name
return
}
button.classList.add('titled-track')
let trackNumber = document.createElement('code')
let title = document.createElement('b')
let artist = document.createElement('cite')
trackNumber.innerText = formatTrackNumber(entry.tags.trackNumber)
title.innerText = entry.tags.title
artist.innerText = entry.tags.artist
button.appendChild(trackNumber)
button.appendChild(title)
button.appendChild(artist)
}
const formatTrackNumber = trackNumber => {
return trackNumber == Infinity ? '--' : trackNumber.toString().padStart(2, '0')
}
const openDirectory = (entry) => {
renderList(entry.entries, entry.root)
list.directory = entry
}
const displayPlaying = () => {
view.playIcon.style.display = "none"
view.pauseIcon.style.display = ""
}
const displayPaused = () => {
view.playIcon.style.display = ""
view.pauseIcon.style.display = "none"
}
/* Population */
const onAddFiles = async () => {
const fileHandle = await window.showDirectoryPicker({
mode: 'read',
startIn: 'music'
})
let entry = await loadDirectory(fileHandle, null, true)
await IDBStore.set('files', entry)
openDirectory(entry)
}
const loadDirectory = async (dirHandle, parent, isRoot) => {
let root = {
entries: [],
type: entryTypes.DIRECTORY,
name: dirHandle.name,
root: isRoot,
parent
}
for await (const [key, value] of dirHandle.entries()) {
if(value instanceof FileSystemDirectoryHandle) {
root.entries.push(
await loadDirectory(value, root)
)
} else if(value instanceof FileSystemFileHandle) {
let entry = await loadEntry(value, dirHandle)
if(entry) {
root.entries.push(entry)
}
}
}
return root
}
const loadEntry = async (fileHandle, dirHandle) => {
if(fileHandle.kind !== "file") {
return
}
let extension = fileHandle.name.split('.').pop()
if(extensions.audio.includes(extension)) {
let entry = {
type: entryTypes.AUDIO,
name: fileHandle.name,
handle: fileHandle
}
return await tagEntry(entry)
}
}
const getTags = blob => new Promise((resolve, reject) => {
jsmediatags.read(blob, {
onSuccess(tags) {
resolve(tags)
},
onError(err) {
reject(err)
}
})
})
const tagEntry = async (entry) => {
let file = await entry.handle.getFile()
let tags = await getTags(file)
.catch(console.error)
.then(data => data?.tags)
if(!tags) {
tags = /((?<track>\d+)\s+)?((?<artist>.+)-\s*)?\s*(?<title>.+)\s*\.\w+/
.exec(entry.name)
.groups
}
entry.tags = {
album: tags.album,
title: tags.title,
artist: tags.artist ?? "Unknown Artist",
trackNumber: tags.track ? parseInt(tags.track) : Infinity
}
return entry
}
// const tagEntry = async (entry) => {
// let file = await entry.handle.getFile()
// let mp3tag = new MP3Tag(await toArrayBuffer(file))
// mp3tag.read()
// entry.tags = mp3tag.tags
// console.log(mp3tag)
// return entry
// }
// const toArrayBuffer = fileOrBlob => new Promise((resolve, reject) => {
// let fileReader = new FileReader()
// fileReader.addEventListener('load', event => {
// resolve(fileReader.result)
// })
// fileReader.addEventListener('error', event => {
// reject(event)
// })
// fileReader.readAsArrayBuffer(fileOrBlob)
// })
/* Playback */
const createPlayback = async entry => {
let file = await entry.handle.getFile()
let url = URL.createObjectURL(file)
let audio = new Audio(url)
return {
file,
audio,
entry
}
}
const startPlayback = (playback) => {
if(player.audio) {
player.audio.pause()
}
let audio = playback.audio
player.audio = playback.audio
player.current = playback
audio.addEventListener('playing', () => {
view.playbackProgress.max = audio.duration
view.trackLength.innerText = formatTime(audio.duration)
displayPlaying()
})
audio.addEventListener('play', displayPlaying)
audio.addEventListener('pause', displayPaused)
audio.addEventListener('ended', () => {
onPlaybackEnded(playback)
})
audio.addEventListener('timeupdate', () => {
onTimeUpdate(playback)
})
view.trackTitle.innerText = playback.entry.tags.title
view.trackArtist.innerText = playback.entry.tags.artist
view.playbackProgress.value = 0
audio.play()
}
const onPlaybackEnded = (playback) => {
player.history.push(playback.entry)
let startedNext = nextTrack()
if(!startedNext) {
player.audio.pause()
player.audio.seek(0)
}
}
const onTimeUpdate = (playback) => {
view.playbackProgress.value = playback.audio.currentTime
view.timestamp.innerText = formatTime(playback.audio.currentTime)
}
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')
}
const playTrack = async (entry) => {
let playback = await createPlayback(entry)
startPlayback(playback)
}
const nextTrack = async () => {
if(player.queue.length > 0) {
startPlayback(await player.queue.shift().playback)
return true
} else {
return false
}
}
const queueTrack = (entry) => {
player.queue.push({
entry,
playback: createPlayback(entry)
})
}
const togglePlay = () => {
if(!player.audio)
return
if(player.audio.paused) {
player.audio.play()
} else {
player.audio.pause()
}
}
const previousTrack = () => {
}
/* Controls */
const onMouseMove = event => {
if(isListFocused()) {
document.activeElement.blur()
}
}
const onKeydown = event => {
let prevent = true
let entry = getSelectedEntry()
switch(event.key) {
case 'ArrowDown':
navigateList(1)
break
case 'ArrowUp':
navigateList(-1)
break
case 'ArrowRight':
case 'Enter':
if(entry) {
openEntry(entry)
}
break
case 'ArrowLeft':
goBack()
break
case 'c':
case ' ':
togglePlay()
break
case 'q':
case '7':
if(entry) {
queueTrack(entry)
}
break
case 'p':
case '8':
if(entry) {
playEntry(entry)
}
break
default:
prevent = false
}
if(prevent) {
event.preventDefault()
}
}
const getSelectedEntry = () => {
if(isListFocused()) {
return getEntry(document.activeElement)
}
}
const isListFocused = () =>
view.list.contains(document.activeElement)
const navigateList = (distance) => {
let index = 0
let children = Array.from(view.list.children)
if(isListFocused()) {
index = parseInt(document.activeElement.dataset.index)
}
index += distance
if(index >= children.length) {
index = 0
} else if(index < 0) {
index = children.length - 1
}
// children[Math.max(0, index - 4)]
let element = children[index].querySelector('button')
children[Math.max(0, index - 7)].scrollIntoView()
element.focus({ preventScroll: true })
}
const goBack = () => {
if(list.directory.parent) {
openDirectory(list.directory.parent)
}
}
const getEntry = (listButton) => {
return list.entries[listButton.dataset.index]
}
const onClickListButton = (listButton) => {
openEntry(getEntry(listButton))
}
const openEntry = entry => {
switch(entry.type) {
case entryTypes.DIRECTORY:
openDirectory(entry)
break
case entryTypes.AUDIO:
playTrack(entry)
break
}
}
const playEntry = entry => {
switch(entry.type) {
case entryTypes.DIRECTORY:
playDirectory(entry)
break
case entryTypes.AUDIO:
playTrack(entry)
break
}
}
const playDirectory = dir => {
let tracks = getAllTracks(dir)
orderTracks(tracks)
playTrack(tracks[0])
for(let i = 1; i < tracks.length; i++) {
queueTrack(tracks[i])
}
}
const getAllTracks = dir => {
let tracks = []
for(entry of dir.entries) {
switch(entry.type) {
case entryTypes.DIRECTORY:
tracks = tracks.concat(getAllTracks(entry))
break
case entryTypes.AUDIO:
tracks.push(entry)
break
}
}
return tracks
}
const grabFocus = () => {
view.list.children[0]?.querySelector('button').focus()
}