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 = /((?\d+)\s+)?((?.+)-\s*)?\s*(?.+)\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() }