forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
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}