526 lines
10 KiB
JavaScript
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()
|
|
} |