A music player that connects to your cloud/distributed storage.
1//
2// Audio engine
3// ♪(´ε` )
4//
5// Creates audio elements and interacts with the Web Audio API.
6
7
8import Timer from "timer.js"
9import * as db from "./indexed-db"
10import { throttle } from "./common"
11import { transformUrl } from "./urls"
12
13
14// ⛩
15
16
17const IS_SAFARI = !!navigator.platform.match(/iPhone|iPod|iPad/) ||
18 navigator.vendor === "Apple Computer, Inc."
19
20
21
22// Audio context
23// -------------
24
25let SINGLE_AUDIO_NODE = IS_SAFARI
26
27
28export function usesSingleAudioNode() {
29 return SINGLE_AUDIO_NODE
30}
31
32
33
34// Container for <audio> elements
35// ------------------------------
36
37const audioElementsContainer = (() => {
38 let c
39 let styles =
40 [ "height: 0"
41 , "width: 0"
42 , "visibility: hidden"
43 , "pointer-events: none"
44 ]
45
46 c = document.createElement("div")
47 c.setAttribute("class", "absolute left-0 top-0")
48 c.setAttribute("style", styles.join("; "))
49
50 return c
51})()
52
53
54function addAudioContainer() {
55 document.body.appendChild(audioElementsContainer)
56}
57
58
59
60// Setup
61// -----
62
63const silentMp3File = "data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV"
64
65
66export function setup(orchestrion) {
67 addAudioContainer()
68
69 if (IS_SAFARI) {
70 // Try to avoid the "couldn't play automatically" error,
71 // which seems to happen with audio nodes using an url created by `createObjectURL`.
72 insertTrack(orchestrion, { url: silentMp3File, trackId: "" }).then(_ => {
73 const temporaryClickHandler = () => {
74 if (orchestrion.audio) orchestrion.audio.play()
75 document.body.removeEventListener("click", temporaryClickHandler)
76 }
77
78 document.body.addEventListener("click", temporaryClickHandler)
79 })
80 }
81}
82
83
84
85// EQ
86// --
87
88let volume = 0.5
89
90export function adjustEqualizerSetting(orchestrion, knobType, value) {
91 switch (knobType) {
92 case "VOLUME":
93 volume = value
94 if (orchestrion.audio) orchestrion.audio.volume = value
95 break;
96 }
97}
98
99
100
101// Playback
102// --------
103
104export function insertTrack(orchestrion, queueItem, maybeArtwork) {
105 if (queueItem.url == undefined) console.error("insertTrack, missing `url`");
106 if (queueItem.trackId == undefined) console.error("insertTrack, missing `trackId`");
107
108 // reset
109 orchestrion.app.ports.setAudioHasStalled.send(false)
110 orchestrion.app.ports.setAudioPosition.send(0)
111 clearTimeout(orchestrion.unstallTimeout)
112 timesStalled = 1
113
114 // metadata
115 setMediaSessionMetadata(queueItem, maybeArtwork)
116
117 // initial promise
118 const initialPromise = queueItem.isCached
119 ? db.getFromIndex({ key: queueItem.trackId, store: db.storeNames.tracks }).then(blobUrl)
120 : transformUrl(queueItem.url, orchestrion.app)
121
122 // find or create audio node
123 let audioNode
124
125 return initialPromise.then(url => {
126 queueItem =
127 Object.assign({}, queueItem, { url: url })
128
129 audioNode =
130 audioElementsContainer.querySelector("audio")
131
132 if (SINGLE_AUDIO_NODE && audioNode) {
133 const crossorigin = isCrossOrginUrl(queueItem.url) ? "use-credentials" : "anonymous"
134 audioNode.setAttribute("crossorigin", crossorigin)
135 audioNode.setAttribute("src", queueItem.url)
136 audioNode.setAttribute("rel", queueItem.trackId)
137 audioNode.load()
138
139 } else if (audioNode = findExistingAudioElement(queueItem)) {
140 audioNode.setAttribute("data-preload", "f")
141 audioNode.setAttribute("data-timestamp", Date.now())
142
143 if (audioNode.readyState >= 4) {
144 playAudio(audioNode, queueItem, orchestrion.app)
145 } else {
146 orchestrion.app.ports.setAudioIsLoading.send(true)
147 audioNode.load()
148 }
149
150 } else {
151 audioNode = createAudioElement(orchestrion, queueItem, Date.now())
152
153 }
154
155 audioNode.volume = volume
156 orchestrion.audio = audioNode
157 })
158}
159
160
161function findExistingAudioElement(queueItem) {
162 return audioElementsContainer.querySelector(`[rel="${queueItem.trackId}"]`)
163}
164
165
166function createAudioElement(orchestrion, queueItem, timestampInMilliseconds, isPreload) {
167 let audio
168
169 const bind = fn => event => {
170 const is = isActiveAudioElement(orchestrion, event.target)
171 if (is) fn.call(orchestrion, event)
172 }
173
174 const crossorigin = isCrossOrginUrl(queueItem.url) ? "use-credentials" : "anonymous"
175
176 audio = new Audio()
177 audio.setAttribute("crossorigin", crossorigin)
178 audio.setAttribute("data-preload", isPreload ? "t" : "f")
179 audio.setAttribute("data-timestamp", timestampInMilliseconds)
180 audio.setAttribute("preload", SINGLE_AUDIO_NODE ? "none" : "auto")
181 audio.setAttribute("rel", queueItem.trackId)
182 audio.setAttribute("src", queueItem.url)
183
184 audio.crossorigin = "anonymous"
185 audio.volume = 1
186
187 audio.addEventListener("canplay", bind(audioCanPlayEvent))
188 audio.addEventListener("ended", bind(audioEndEvent))
189 audio.addEventListener("error", bind(audioErrorEvent))
190 audio.addEventListener("loadstart", bind(audioLoading))
191 audio.addEventListener("loadeddata", bind(audioLoaded))
192 audio.addEventListener("pause", bind(audioPauseEvent))
193 audio.addEventListener("play", bind(audioPlayEvent))
194 audio.addEventListener("seeking", bind(audioLoading))
195 audio.addEventListener("seeked", bind(audioLoaded))
196 audio.addEventListener("timeupdate", bind(audioTimeUpdateEvent))
197
198 // `stalled` event doesn't work properly (mostly on Safari and mobile devices)
199 // if (!IS_SAFARI) audio.addEventListener("stalled", bind(audioStalledEvent))
200
201 audio.load()
202 audioElementsContainer.appendChild(audio)
203
204 return audio
205}
206
207
208export function preloadAudioElement(orchestrion, queueItem) {
209 // already loaded?
210 if (findExistingAudioElement(queueItem)) return
211
212 // remove other preloads
213 audioElementsContainer.querySelectorAll(`[data-preload="t"]`).forEach(
214 n => n.parentNode.removeChild(n)
215 )
216
217 // audio element remains valid for 45 minutes
218 transformUrl(queueItem.url, orchestrion.app).then(url => {
219 const queueItemWithTransformedUrl =
220 Object.assign({}, queueItem, { url: url })
221
222 createAudioElement(
223 orchestrion,
224 queueItemWithTransformedUrl,
225 Date.now() + 1000 * 60 * 45,
226 true
227 )
228 })
229}
230
231
232export function seek(audio, percentage) {
233 if (audio && !isNaN(audio.duration)) {
234 if (audio.paused) audio.play()
235 audio.currentTime = audio.duration * percentage
236 }
237}
238
239
240export function isCrossOrginUrl(url) {
241 return url.includes("service_worker_authentication")
242}
243
244
245
246// Audio events
247// ------------
248
249let showedNoNetworkError = false
250let timesStalled = 1
251
252
253function audioErrorEvent(event) {
254 this.app.ports.setAudioIsPlaying.send(false)
255
256 switch (event.target.error.code) {
257 case event.target.error.MEDIA_ERR_ABORTED:
258 console.error("You aborted the audio playback.")
259 break
260 case event.target.error.MEDIA_ERR_NETWORK:
261 console.error("A network error caused the audio download to fail.")
262 showNetworkErrorNotification.call(this)
263 audioStalledEvent.call(this, event)
264 break
265 case event.target.error.MEDIA_ERR_DECODE:
266 console.error("The audio playback was aborted due to a corruption problem or because the video used features your browser did not support.")
267 break
268 case event.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED:
269 console.error("The audio not be loaded, either because the server or network failed or because the format is not supported.")
270 if (event.target.currentTime && event.target.currentTime > 0) {
271 showNetworkErrorNotification.call(this)
272 audioStalledEvent.call(this, event)
273 } else if (navigator.onLine) {
274 showUnsupportedSrcErrorNotification.call(this)
275 clearTimeout(this.loadingTimeoutId)
276 this.app.ports.setAudioIsLoading.send(false)
277 } else {
278 showNetworkErrorNotification.call(this)
279 audioStalledEvent.call(this, event)
280 }
281 break
282 default:
283 console.error("An unknown error occurred.")
284 }
285}
286
287
288function showNetworkErrorNotification() {
289 if (showedNoNetworkError) return
290 showedNoNetworkError = true
291 this.app.ports.showErrorNotification.send(
292 navigator.onLine
293 ? "I can't play this track because of a network error. I'll try to reconnect."
294 : "I can't play this track because we're offline. I'll try to reconnect."
295 )
296}
297
298
299function showUnsupportedSrcErrorNotification() {
300 this.app.ports.showErrorNotification.send(
301 "__I can't play this track because your browser didn't recognize it.__ Try checking your developer console for a warning to find out why."
302 )
303}
304
305
306function audioStalledEvent(event, notifyAppImmediately) {
307 this.app.ports.setAudioIsLoading.send(true)
308 clearTimeout(this.unstallTimeout)
309
310 // Notify app
311 if (timesStalled >= 3 || notifyAppImmediately) {
312 this.app.ports.setAudioHasStalled.send(true)
313 }
314
315 // Timeout
316 setTimeout(_ => {
317 if (isActiveAudioElement(this, event.target)) {
318 unstallAudio.call(this, event.target)
319 }
320 }, timesStalled * 2500)
321
322 // Increase counter
323 timesStalled++
324}
325
326
327function audioTimeUpdateEvent(event) {
328 const node = event.target
329
330 if (
331 isNaN(node.duration) ||
332 isNaN(node.currentTime) ||
333 node.duration === 0
334 ) return;
335
336 setDurationIfNecessary.call(this, node)
337 this.app.ports.setAudioPosition.send(node.currentTime)
338
339 if (navigator.mediaSession && navigator.mediaSession.setPositionState) {
340 navigator.mediaSession.setPositionState({
341 duration: node.duration,
342 position: node.currentTime
343 })
344 }
345
346 const progress = node.currentTime / node.duration
347
348 if (node.duration >= 30 * 60) {
349 sendProgress(this, progress)
350 }
351}
352
353
354function audioEndEvent(event) {
355 if (this.repeat) {
356 event.target.startedPlayingAt = Math.floor(Date.now() / 1000)
357 if (this.scrobbleTimer) this.scrobbleTimer.stop()
358 playAudio(event.target, this.activeQueueItem, this.app)
359 } else {
360 this.app.ports.noteProgress.send({ trackId: this.activeQueueItem.trackId, progress: 1 })
361 this.app.ports.activeQueueItemEnded.send(null)
362 }
363}
364
365
366function audioLoading(_event) {
367 clearTimeout(this.loadingTimeoutId)
368
369 this.loadingTimeoutId = setTimeout(() => {
370 if (!this.audio) {
371 return
372 } else if (this.audio.readyState === 4 && this.audio.currentTime === 0) {
373 this.app.ports.setAudioIsLoading.send(false)
374 } else {
375 this.app.ports.setAudioIsLoading.send(true)
376 }
377 }, 1750)
378}
379
380
381function audioLoaded(event) {
382 clearTimeout(this.loadingTimeoutId)
383 this.app.ports.setAudioHasStalled.send(false)
384 this.app.ports.setAudioIsLoading.send(false)
385 if (event.target.paused) playAudio(event.target, this.activeQueueItem, this.app)
386}
387
388
389function audioPlayEvent(_event) {
390 this.app.ports.setAudioIsPlaying.send(true)
391 if (navigator.mediaSession) navigator.mediaSession.playbackState = "playing"
392 if (this.scrobbleTimer) this.scrobbleTimer.start()
393}
394
395
396function audioPauseEvent(_event) {
397 this.app.ports.setAudioIsPlaying.send(false)
398 if (navigator.mediaSession) navigator.mediaSession.playbackState = "paused"
399 if (this.scrobbleTimer) this.scrobbleTimer.pause()
400}
401
402
403function audioCanPlayEvent(event) {
404 showedNoNetworkError = false
405 setDurationIfNecessary.call(this, event.target)
406}
407
408
409
410// 🖍 Utensils
411// -----------
412
413function audioElementTrackId(node) {
414 return node ? node.getAttribute("rel") : undefined
415}
416
417
418function blobUrl(blob) {
419 return URL.createObjectURL(blob)
420}
421
422
423function isActiveAudioElement(orchestrion, node) {
424 return (
425 !orchestrion.activeQueueItem ||
426 !node ||
427 node.getAttribute("data-preload") === "t"
428 )
429 ? false
430 : orchestrion.activeQueueItem.trackId === audioElementTrackId(node)
431}
432
433
434function playAudio(element, queueItem, app) {
435 if (queueItem.progress && element.duration) {
436 element.currentTime = queueItem.progress * element.duration
437 }
438
439 const promise = element.play() || Promise.resolve()
440
441 promise.catch(e => {
442 // SINGLE_AUDIO_NODE = true
443
444 const err = "Couldn't play audio automatically. Please resume playback manually."
445 console.error(err, e)
446 if (app) app.ports.fromAlien.send({ tag: "", data: null, error: err })
447 })
448}
449
450
451const sendProgress = throttle((orchestrion, progress) => {
452 orchestrion.app.ports.noteProgress.send({
453 trackId: orchestrion.activeQueueItem.trackId,
454 progress: progress
455 })
456}, 30000)
457
458
459let lastSetDuration = 0
460
461
462function setDurationIfNecessary(audio) {
463 if (audio.duration === lastSetDuration) return;
464
465 this.app.ports.setAudioDuration.send(audio.duration || 0)
466 lastSetDuration = audio.duration
467
468 // Scrobble
469 if (!lastSetDuration || lastSetDuration < 30) return;
470
471 const timestamp = Math.floor(Date.now() / 1000)
472 const scrobbleTimeoutDuration = Math.min(240 + 0.5, lastSetDuration / 1.95)
473 const trackId = audio.getAttribute("rel")
474
475 audio.startedPlayingAt = timestamp
476
477 this.scrobbleTimer = new Timer({
478 onend: _ => this.app.ports.scrobble.send({
479 duration: Math.round(lastSetDuration),
480 timestamp: audio.startedPlayingAt || timestamp,
481 trackId: trackId
482 })
483 })
484
485 this.scrobbleTimer.start(scrobbleTimeoutDuration)
486}
487
488
489export function setMediaSessionMetadata(queueItem, maybeArtwork) {
490 if ("mediaSession" in navigator === false || !queueItem.trackTags) return
491
492 let artwork = []
493
494 if (maybeArtwork && typeof maybeArtwork !== "string") {
495 artwork = [ {
496 src: URL.createObjectURL(maybeArtwork),
497 type: maybeArtwork.type
498 } ]
499 }
500
501 navigator.mediaSession.metadata = new MediaMetadata({
502 title: queueItem.trackTags.title,
503 artist: queueItem.trackTags.artist,
504 album: queueItem.trackTags.album,
505 artwork: artwork
506 })
507}
508
509
510function unstallAudio(node) {
511 const time = node.currentTime
512
513 node.load()
514 node.currentTime = time
515
516 if (timesStalled > 5 && !showedNoNetworkError && navigator.onLine) {
517 this.app.ports.showStickyErrorNotification.send(
518 "You loaded too many tracks too quickly, " +
519 "which the browser can't handle. " +
520 "You'll most likely have to reload the browser."
521 )
522 }
523}
524
525
526
527// 💥
528// --
529// Remove all the audio elements with a timestamp older than the given one.
530
531export function removeOlderAudioElements(timestamp) {
532 const nodes = audioElementsContainer.querySelectorAll("audio[data-timestamp]")
533
534 nodes.forEach(node => {
535 const t = parseInt(node.getAttribute("data-timestamp"), 10)
536 if (t >= timestamp) return
537
538 // Force browser to stop loading
539 node.src = silentMp3File
540
541 // Remove element
542 audioElementsContainer.removeChild(node)
543 })
544}