A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 533 lines 13 kB view raw
1// 2// Audio engine 3// ♪(´ε` ) 4 5import type { App } from "./elm/types" 6 7import Timer from "timer.js" 8import { debounce } from "throttle-debounce" 9import { CoverPrep, db, mimeType } from "../common" 10import { albumCover, loadAlbumCovers } from "./artwork" 11import { transformUrl } from "../urls" 12 13 14// 🏔️ 15 16 17const silentMp3File = 18 "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV" 19 20 21let app: App 22let container: Element | null = null 23let scrobbleTimer: Timer | null = null 24 25 26 27// 🚀 28 29 30export function init(a: App) { 31 app = a 32 33 app.ports.adjustEqualizerSetting.subscribe(adjustEqualizerSetting) 34 app.ports.pause.subscribe(pause) 35 app.ports.pauseScrobbleTimer.subscribe(pauseScrobbleTimer) 36 app.ports.play.subscribe(play) 37 app.ports.reloadAudioNodeIfNeeded.subscribe(reloadAudioNodeIfNeeded) 38 app.ports.renderAudioElements.subscribe(renderAudioElements) 39 app.ports.resetScrobbleTimer.subscribe(resetScrobbleTimer) 40 app.ports.seek.subscribe(seek) 41 app.ports.setMediaSessionArtwork.subscribe(setMediaSessionArtwork) 42 app.ports.setMediaSessionMetadata.subscribe(setMediaSessionMetadata) 43 app.ports.setMediaSessionPlaybackState.subscribe(setMediaSessionPlaybackState) 44 app.ports.setMediaSessionPositionState.subscribe(setMediaSessionPositionState) 45 app.ports.startScrobbleTimer.subscribe(startScrobbleTimer) 46} 47 48 49 50// 🌳 51 52 53/** 54 * Javascript representation of `Queue.EngineItem` in Elm. 55 */ 56type EngineItem = { 57 isCached: boolean 58 isPreload: boolean 59 progress: number | null 60 sourceId: string 61 trackId: string 62 trackTags: TrackTags 63 trackPath: string 64 url: string 65} 66 67 68/**/ 69type TrackTags = { 70 disc: number 71 nr: number 72 73 // Main 74 album: string | null 75 artist: string | null 76 title: string 77 78 // Extra 79 genre: string | null 80 picture: string | null 81 year: number | null 82} 83 84 85 86// Ports 87// ----- 88 89 90function adjustEqualizerSetting({ knob, value }: { knob: string, value: number }): void { 91 switch (knob) { 92 case "VOLUME": 93 Array.from( 94 document.body.querySelectorAll('#audio-elements audio[data-is-preload="false"]'), 95 ).forEach((audio) => ((audio as HTMLAudioElement).volume = value)) 96 break 97 } 98} 99 100 101function pause({ trackId }: { trackId: string }) { 102 withAudioNode(trackId, (audio) => audio.pause()) 103} 104 105 106function pauseScrobbleTimer() { 107 if (scrobbleTimer) scrobbleTimer.pause() 108} 109 110 111function play({ trackId, volume }: { trackId: string, volume: number }) { 112 withAudioNode(trackId, (audio) => { 113 audio.volume = volume 114 audio.muted = false 115 116 if (audio.readyState === 0) audio.load() 117 if (!audio.isConnected) return 118 119 const promise = audio.play() || Promise.resolve() 120 const didPreload = audio.getAttribute("data-did-preload") === "true" 121 const isPreload = audio.getAttribute("data-is-preload") === "true" 122 123 if (didPreload && !isPreload) { 124 audio.removeAttribute("data-did-preload") 125 app.ports.audioDurationChange.send({ 126 trackId: audio.id, 127 duration: audio.duration, 128 }) 129 } 130 131 promise.catch((e) => { 132 if (!audio.isConnected) return /* The node was removed from the DOM, we can ignore this error */ 133 const err = "Couldn't play audio automatically. Please resume playback manually." 134 console.error(err, e) 135 if (app) app.ports.fromAlien.send({ tag: "", data: null, error: err }) 136 }) 137 }) 138} 139 140 141async function reloadAudioNodeIfNeeded(args: { play: boolean, progress: number | null, trackId: string }) { 142 withAudioNode(args.trackId, (audio) => { 143 if (audio.readyState === 0 || audio.error?.code === 2) { 144 audio.load() 145 146 if (args.progress) { 147 audio.setAttribute("data-initial-progress", JSON.stringify(args.progress)) 148 } 149 150 if (args.play) { 151 play({ trackId: args.trackId, volume: audio.volume }) 152 } 153 } 154 }) 155} 156 157 158async function renderAudioElements(args: { 159 items: Array<EngineItem> 160 play: string | null 161 volume: number 162}) { 163 await render(args.items) 164 if (args.play) play({ trackId: args.play, volume: args.volume }) 165} 166 167 168function resetScrobbleTimer({ duration, trackId }: { duration: number, trackId: string }) { 169 const timestamp = Math.round(Date.now() / 1000) 170 const scrobbleTimeoutDuration = Math.min(240 + 0.5, duration / 1.95) 171 172 if (scrobbleTimer) scrobbleTimer.stop() 173 174 scrobbleTimer = new Timer({ 175 onend: () => { 176 scrobbleTimer = undefined 177 app.ports.scrobble.send({ 178 duration: Math.round(duration), 179 timestamp, 180 trackId, 181 }) 182 } 183 }) 184 185 scrobbleTimer.start(scrobbleTimeoutDuration) 186} 187 188 189function seek({ percentage, trackId }: { percentage: number, trackId: string }) { 190 withAudioNode(trackId, (audio) => { 191 if (!isNaN(audio.duration)) { 192 audio.currentTime = audio.duration * percentage 193 } 194 }) 195} 196 197 198async function setMediaSessionArtwork({ blobUrl, imageType }: { blobUrl: string, imageType: string }) { 199 const artwork: MediaImage[] = [{ 200 src: blobUrl, 201 type: imageType 202 }] 203 204 navigator.mediaSession.metadata = new MediaMetadata({ 205 title: navigator.mediaSession.metadata?.title, 206 artist: navigator.mediaSession.metadata?.artist, 207 album: navigator.mediaSession.metadata?.album, 208 artwork: artwork, 209 }) 210} 211 212 213async function setMediaSessionMetadata({ 214 album, 215 artist, 216 title, 217 218 coverPrep, 219}: { 220 album: string | null 221 artist: string | null 222 title: string 223 224 coverPrep: CoverPrep | null 225}) { 226 let artwork: MediaImage[] = [] 227 228 if (coverPrep) { 229 const blob = await albumCover(coverPrep.cacheKey) 230 231 artwork = blob && typeof blob !== "string" 232 ? [{ 233 src: URL.createObjectURL(blob), 234 type: blob.type 235 }] 236 : [] 237 238 if (!blob) { 239 // Download artwork and set it later 240 loadAlbumCovers([coverPrep]) 241 } 242 } 243 244 navigator.mediaSession.metadata = new MediaMetadata({ 245 title, 246 artist: artist || undefined, 247 album: album || undefined, 248 artwork: artwork, 249 }) 250} 251 252 253function setMediaSessionPlaybackState(state: MediaSessionPlaybackState) { 254 if (navigator.mediaSession) navigator.mediaSession.playbackState = state 255} 256 257 258function setMediaSessionPositionState({ 259 currentTime, 260 duration, 261}: { 262 currentTime: number 263 duration: number 264}) { 265 try { 266 navigator?.mediaSession?.setPositionState({ 267 duration: duration, 268 position: currentTime, 269 }) 270 } catch (_err) { 271 // 272 } 273} 274 275 276function startScrobbleTimer() { 277 if (scrobbleTimer) scrobbleTimer.start() 278} 279 280 281 282// Media Keys 283// ---------- 284 285 286if ("mediaSession" in navigator) { 287 navigator.mediaSession.setActionHandler("play", () => { 288 app.ports.requestPlay.send(null) 289 }) 290 291 navigator.mediaSession.setActionHandler("pause", () => { 292 app.ports.requestPause.send(null) 293 }) 294 295 navigator.mediaSession.setActionHandler("previoustrack", () => { 296 app.ports.requestPrevious.send(null) 297 }) 298 299 navigator.mediaSession.setActionHandler("nexttrack", () => { 300 app.ports.requestNext.send(null) 301 }) 302 303 navigator.mediaSession.setActionHandler("seekbackward", (event) => { 304 const seekOffset = event.seekOffset || 10 305 withActiveAudioNode( 306 (audio) => (audio.currentTime = Math.max(audio.currentTime - seekOffset, 0)), 307 ) 308 }) 309 310 navigator.mediaSession.setActionHandler("seekforward", (event) => { 311 const seekOffset = event.seekOffset || 10 312 withActiveAudioNode( 313 (audio) => (audio.currentTime = Math.min(audio.currentTime + seekOffset, audio.duration)), 314 ) 315 }) 316 317 navigator.mediaSession.setActionHandler("seekto", (event) => { 318 withActiveAudioNode((audio) => (audio.currentTime = event.seekTime || audio.currentTime)) 319 }) 320} 321 322 323 324// 🖼️ 325 326 327async function render(items: Array<EngineItem>) { 328 if (!container) { 329 container = document.createElement("div") 330 container.id = "audio-elements" 331 container.className = "absolute h-0 invisible left-0 pointer-events-none top-0 w-0" 332 333 document.body.appendChild(container) 334 } 335 336 const trackIds = items.map((e) => e.trackId) 337 const existingNodes = {} 338 339 // Manage existing nodes 340 Array.from(container.querySelectorAll("audio")).map((node: HTMLAudioElement) => { 341 if (trackIds.includes(node.id)) { 342 existingNodes[node.id] = node 343 } else { 344 node.src = silentMp3File 345 container?.removeChild(node) 346 } 347 }) 348 349 // Adjust existing and add new 350 await items.reduce(async (acc: Promise<void>, item: EngineItem) => { 351 await acc 352 353 const existingNode = existingNodes[item.trackId] 354 355 if (existingNode) { 356 const isPreload = existingNode.getAttribute("data-is-preload") 357 if (isPreload === "true") existingNode.setAttribute("data-did-preload", "true") 358 359 existingNode.setAttribute( 360 "data-is-preload", 361 item.isPreload ? "true" : "false", 362 ) 363 } else { 364 await createElement(item) 365 } 366 }, Promise.resolve()) 367} 368 369 370export async function createElement(item: EngineItem) { 371 const url = item.isCached 372 ? await db("tracks") 373 .getItem(item.trackId) 374 .then((blob) => (blob ? URL.createObjectURL(blob as Blob) : item.url)) 375 : await transformUrl(item.url, app) 376 377 // Mime + SRC 378 const fileName = item.trackPath.split("/").reverse()[0] 379 const fileExtMatch = fileName.match(/\.(\w+)$/) 380 const fileExt = fileExtMatch && fileExtMatch[1] 381 const mime = fileExt ? mimeType(fileExt) : null 382 383 const source = document.createElement("source") 384 if (mime) source.setAttribute("type", mime) 385 source.setAttribute("src", url) 386 387 // Audio node 388 const audio = new Audio() 389 audio.setAttribute("id", item.trackId) 390 audio.setAttribute("crossorigin", "anonymous") 391 audio.setAttribute("data-initial-progress", JSON.stringify(item.progress)) 392 audio.setAttribute("data-is-preload", item.isPreload ? "true" : "false") 393 audio.setAttribute("muted", "true") 394 audio.setAttribute("preload", "auto") 395 audio.appendChild(source) 396 397 audio.addEventListener("canplay", canplayEvent) 398 audio.addEventListener("durationchange", durationchangeEvent) 399 audio.addEventListener("ended", endedEvent) 400 audio.addEventListener("error", errorEvent) 401 audio.addEventListener("pause", pauseEvent) 402 audio.addEventListener("play", playEvent) 403 audio.addEventListener("suspend", suspendEvent) 404 audio.addEventListener("timeupdate", timeupdateEvent) 405 audio.addEventListener("waiting", debounce(1500, waitingEvent)) 406 407 container?.appendChild(audio) 408} 409 410 411 412// 🖼 ░░ EVENTS 413 414 415function canplayEvent(event: Event) { 416 const target = event.target as HTMLAudioElement 417 418 if (target.hasAttribute("data-initial-progress") && target.duration && !isNaN(target.duration)) { 419 const progress = JSON.parse(target.getAttribute("data-initial-progress") as string) 420 target.currentTime = target.duration * progress 421 target.removeAttribute("data-initial-progress") 422 } 423 424 finishedLoading(event) 425} 426 427 428function durationchangeEvent(event: Event) { 429 const target = event.target as HTMLAudioElement 430 431 if (!isNaN(target.duration)) { 432 app.ports.audioDurationChange.send({ 433 trackId: target.id, 434 duration: target.duration, 435 }) 436 } 437} 438 439function endedEvent(event: Event) { 440 app.ports.audioEnded.send({ 441 trackId: (event.target as HTMLAudioElement).id, 442 }) 443} 444 445function errorEvent(event: Event) { 446 const audio = event.target as HTMLAudioElement 447 448 app.ports.audioError.send({ 449 trackId: audio.id, 450 code: audio.error?.code || 0 451 }) 452} 453 454 455function pauseEvent(event: Event) { 456 app.ports.audioPlaybackStateChanged.send({ 457 trackId: (event.target as HTMLAudioElement).id, 458 isPlaying: false, 459 }) 460} 461 462 463function playEvent(event: Event) { 464 const audio = event.target as HTMLAudioElement 465 466 app.ports.audioPlaybackStateChanged.send({ 467 trackId: audio.id, 468 isPlaying: true, 469 }) 470 471 // In case audio was preloaded: 472 if (audio.readyState === 4) finishedLoading(event) 473} 474 475 476function suspendEvent(event: Event) { 477 finishedLoading(event) 478} 479 480 481function timeupdateEvent(event: Event) { 482 const target = event.target as HTMLAudioElement 483 484 app.ports.audioTimeUpdated.send({ 485 trackId: target.id, 486 currentTime: target.currentTime, 487 duration: isNaN(target.duration) ? null : target.duration, 488 }) 489} 490 491 492function waitingEvent(event: Event) { 493 initiateLoading(event) 494} 495 496 497 498// 🛠️ 499 500 501function finishedLoading(event: Event) { 502 app.ports.audioHasLoaded.send({ 503 trackId: (event.target as HTMLAudioElement).id, 504 }) 505} 506 507 508function initiateLoading(event: Event) { 509 const audio = event.target as HTMLAudioElement 510 511 if (audio.readyState < 4) 512 app.ports.audioIsLoading.send({ 513 trackId: audio.id, 514 }) 515} 516 517 518function withActiveAudioNode(fn: (node: HTMLAudioElement) => void): void { 519 const nonPreloadNodes: HTMLAudioElement[] = Array.from( 520 document.body.querySelectorAll(`#audio-elements audio[data-is-preload="false"]`), 521 ) 522 const playingNodes = nonPreloadNodes.filter((n) => n.paused === false) 523 const node = playingNodes.length ? playingNodes[0] : nonPreloadNodes[0] 524 if (node) fn(node) 525} 526 527 528function withAudioNode(trackId: string, fn: (node: HTMLAudioElement) => void): void { 529 const node = document.body.querySelector( 530 `#audio-elements audio[id="${trackId}"][data-is-preload="false"]`, 531 ) 532 if (node) fn(node as HTMLAudioElement) 533}