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

Configure Feed

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

at df22eb08f006cc55f4e454a905fb314660883ed9 544 lines 15 kB view raw
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}