A music player that connects to your cloud/distributed storage.
at v4 573 lines 16 kB view raw
1import { FastAverageColor } from "fast-average-color"; 2import { Temporal } from "~/common/temporal.js"; 3import { cache } from "lit-html/directives/cache.js"; 4import { debounce } from "throttle-debounce"; 5import { xxh32r } from "xxh32/dist/raw.js"; 6 7import { 8 DEFAULT_GROUP, 9 DiffuseElement, 10 query, 11 whenElementsDefined, 12} from "~/common/element.js"; 13 14import { computed, signal, untracked } from "~/common/signal.js"; 15 16/** 17 * @import {RenderArg} from "~/common/element.d.ts" 18 * 19 * @import {InputElement} from "~/components/input/types.d.ts" 20 * @import {OutputElement} from "~/components/output/types.d.ts" 21 * @import {Artwork} from "~/components/processor/artwork/types.d.ts" 22 * @import AudioEngine from "~/components/engine/audio/element.js" 23 * @import QueueEngine from "~/components/engine/queue/element.js" 24 * @import ArtworkProcessor from "~/components/processor/artwork/element.js" 25 * @import FavouritesOrchestrator from "~/components/orchestrator/favourites/element.js" 26 */ 27 28class ArtworkController extends DiffuseElement { 29 constructor() { 30 super(); 31 this.attachShadow({ mode: "open" }); 32 } 33 34 // VARIABLES 35 36 /** @type {number | undefined} */ 37 #isLoadingTimeout = undefined; 38 39 // SIGNALS 40 41 #artwork = signal( 42 /** @type {{ current: (Artwork & { hash: string; index: number; loaded: boolean; url: string }) | null; previous: (Artwork & { hash: string; index: number; loaded: boolean; url: string }) | null }} */ ({ 43 current: null, 44 previous: null, 45 }), 46 ); 47 48 #artworkColor = signal(/** @type {string | undefined} */ (undefined)); 49 #artworkLightMode = signal(false); 50 #duration = signal("0:00"); 51 #isLoading = signal(true); 52 #time = signal("0:00"); 53 54 // SIGNALS - DEPENDENCIES 55 56 $artwork = signal(/** @type {ArtworkProcessor | undefined} */ (undefined)); 57 $audio = signal(/** @type {AudioEngine | undefined} */ (undefined)); 58 $favourites = signal( 59 /** @type {FavouritesOrchestrator | undefined} */ (undefined), 60 ); 61 $input = signal(/** @type {InputElement | undefined} */ (undefined)); 62 $output = signal(/** @type {OutputElement | undefined} */ (undefined)); 63 $queue = signal(/** @type {QueueEngine | undefined} */ (undefined)); 64 65 // SIGNALS - COMPUTED 66 67 audio = computed(() => { 68 const curr = this.$queue.value?.now(); 69 return curr ? this.$audio.value?.state(curr.id) : undefined; 70 }); 71 72 currentTrack = computed(() => { 73 const item = this.$queue.value?.now(); 74 if (!item) return undefined; 75 const col = this.$output.value?.tracks.collection(); 76 if (!col || col.state !== "loaded") return undefined; 77 return col.data.find((t) => t.id === item.id); 78 }); 79 80 isPlaying = computed(() => { 81 return this.$audio.value?.isPlaying(); 82 }); 83 84 // LIFECYCLE 85 86 /** 87 * @override 88 */ 89 connectedCallback() { 90 super.connectedCallback(); 91 92 /** @type {ArtworkProcessor} */ 93 const artwork = query(this, "artwork-processor-selector"); 94 95 /** @type {AudioEngine} */ 96 const audio = query(this, "audio-engine-selector"); 97 98 /** @type {InputElement} */ 99 const input = query(this, "input-selector"); 100 101 /** @type {OutputElement} */ 102 const output = query(this, "output-selector"); 103 104 /** @type {QueueEngine} */ 105 const queue = query(this, "queue-engine-selector"); 106 107 /** @type {FavouritesOrchestrator} */ 108 const favourites = query(this, "favourites-orchestrator-selector"); 109 110 whenElementsDefined({ audio, artwork, favourites, input, output, queue }) 111 .then( 112 () => { 113 this.$artwork.value = artwork; 114 this.$audio.value = audio; 115 this.$input.value = input; 116 this.$output.value = output; 117 this.$queue.value = queue; 118 this.$favourites.value = favourites; 119 120 // Changed artwork based on active queue item. 121 const debouncedChangeArtwork = debounce( 122 1000, 123 this.#setArtwork.bind(this), 124 ); 125 126 this.effect(() => { 127 const _trigger = this.currentTrack(); 128 debouncedChangeArtwork(); 129 }); 130 131 this.effect(() => this.#formatTimestamps()); 132 this.effect(() => this.#lightOrDark()); 133 134 this.effect(() => { 135 const now = !!queue.now(); 136 const aud = this.audio()?.loadingState(); 137 const bool = now && aud !== "loaded"; 138 139 if (this.#isLoadingTimeout) { 140 clearTimeout(this.#isLoadingTimeout); 141 } 142 143 if (bool) { 144 this.#isLoadingTimeout = setTimeout( 145 () => this.#isLoading.value = true, 146 2000, 147 ); 148 } else { 149 this.#isLoading.value = false; 150 } 151 }); 152 }, 153 ); 154 } 155 156 //////////////////////////////////////////// 157 // ✨ EFFECTS 158 // 🖼️ Artwork 159 //////////////////////////////////////////// 160 161 #lightOrDark() { 162 const controller = this.root().querySelector(".controller__inner"); 163 if (!controller) return; 164 165 if (this.#artworkLightMode.value) { 166 controller.classList.add("controller__inner--light-mode"); 167 } else controller.classList.remove("controller__inner--light-mode"); 168 } 169 170 /** */ 171 async #setArtwork() { 172 const track = this.currentTrack(); 173 const currArtwork = untracked(this.#artwork.get); 174 175 if (!track) { 176 if (currArtwork.current) { 177 this.#artwork.value = { current: null, previous: currArtwork.current }; 178 } 179 return; 180 } 181 182 const cacheId = track.id; 183 184 const resGet = await this.$input.value?.resolve({ 185 method: "GET", 186 uri: track.uri, 187 }); 188 189 const resHead = await this.$input.value?.resolve({ 190 method: "HEAD", 191 uri: track.uri, 192 }); 193 194 if (!resGet) return; 195 196 const request = "stream" in resGet 197 ? { 198 cacheId, 199 stream: resGet.stream, 200 tags: track.tags, 201 } 202 : { 203 cacheId, 204 tags: track.tags, 205 urls: { 206 get: resGet.url, 207 head: resHead && "url" in resHead ? resHead.url : resGet.url, 208 }, 209 }; 210 211 if (this.$queue.value?.now()?.id !== track?.id) { 212 return; 213 } 214 215 const allArt = await this.$artwork.value?.artwork(request) ?? []; 216 217 // Check if queue item has changed while fetching the artwork 218 const currTrack = this.currentTrack(); 219 const currCacheId = currTrack ? currTrack.id : undefined; 220 221 if (cacheId === currCacheId) { 222 const art = allArt[0]; 223 224 this.#artwork.set({ 225 previous: currArtwork.current 226 ? { ...currArtwork.current, loaded: false } 227 : null, 228 current: art 229 ? { 230 ...art, 231 hash: xxh32r(art.bytes).toString(), 232 index: (currArtwork.current?.index ?? 0) + 1, 233 loaded: false, 234 url: URL.createObjectURL( 235 new Blob( 236 [/** @type {ArrayBuffer} */ (art.bytes.buffer)], 237 { type: art.mime }, 238 ), 239 ), 240 } 241 : null, 242 }); 243 244 if (!art) { 245 this.#artworkColor.value = undefined; 246 this.#artworkLightMode.value = false; 247 } 248 } 249 } 250 251 //////////////////////////////////////////// 252 // ✨ EFFECTS 253 // ⌚️ Time 254 //////////////////////////////////////////// 255 #formatTimestamps() { 256 const currTrack = this.currentTrack(); 257 const audio = this.audio(); 258 const curMs = (audio?.currentTime() ?? 0) * 1000; 259 const durMs = currTrack?.stats?.duration ?? 260 (audio?.duration() != null ? audio.duration() * 1000 : undefined); 261 262 if (audio && durMs && !isNaN(durMs)) { 263 const p = Temporal.Duration.from({ 264 milliseconds: Math.round(curMs), 265 }).round({ 266 largestUnit: "hours", 267 smallestUnit: "seconds", 268 }); 269 270 if (durMs === Infinity) { 271 this.#time.value = this.#formatTime(p); 272 this.#duration.value = "∞"; 273 return; 274 } 275 276 const d = Temporal.Duration.from({ milliseconds: Math.round(durMs) }) 277 .round({ 278 largestUnit: "hours", 279 smallestUnit: "seconds", 280 }); 281 282 this.#time.value = this.#formatTime(p); 283 this.#duration.value = this.#formatTime(d); 284 } else { 285 this.#time.value = "0:00"; 286 this.#duration.value = "0:00"; 287 } 288 } 289 290 /** 291 * @param {import("temporal-polyfill").Temporal.Duration} duration 292 */ 293 #formatTime(duration) { 294 return `${duration.hours > 0 ? duration.hours.toFixed(0) + ":" : ""}${ 295 duration.hours > 0 296 ? (duration.minutes > 9 297 ? duration.minutes.toFixed(0) 298 : "0" + duration.minutes.toFixed(0)) 299 : duration.minutes.toFixed(0) 300 }:${ 301 duration.seconds > 9 302 ? duration.seconds.toFixed(0) 303 : "0" + duration.seconds.toFixed(0) 304 }`; 305 } 306 307 // EVENTS 308 309 /** 310 * @param {Event} event 311 */ 312 artworkLoaded = (event) => { 313 if (!(event.target instanceof HTMLImageElement)) return; 314 315 const hash = event.target.getAttribute("data-hash"); 316 if (!hash) return; 317 318 if (hash !== this.#artwork.value.current?.hash) return; 319 if (this.#artwork.value.current?.loaded) return; 320 321 const fac = new FastAverageColor(); 322 const color = fac.getColor(event.target); 323 const rgb = color.value; 324 const o = Math.round( 325 (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000, 326 ); 327 328 this.#artworkColor.value = color.rgba; 329 this.#artworkLightMode.value = o > 165; 330 this.#artwork.value = { 331 previous: this.#artwork.value.previous, 332 current: { ...this.#artwork.value.current, loaded: true }, 333 }; 334 }; 335 336 fullVolume = () => { 337 this.$audio.value?.adjustVolume({ volume: 1 }); 338 }; 339 340 mute = () => { 341 this.$audio.value?.adjustVolume({ volume: 0 }); 342 }; 343 344 next = () => { 345 this.$queue.value?.shift(); 346 }; 347 348 playPause = () => { 349 const audioId = this.$queue.value?.now()?.id; 350 351 if (this.isPlaying() && audioId) { 352 this.$audio.value?.pause({ audioId }); 353 } else if (audioId) { 354 this.$audio.value?.play({ audioId }); 355 } 356 }; 357 358 previous = () => { 359 this.$queue.value?.unshift(); 360 }; 361 362 /** 363 * @param {MouseEvent} event 364 */ 365 seek = (event) => { 366 const target = event.target 367 ? /** @type {HTMLProgressElement} */ (event.target) 368 : null; 369 const percentage = target ? event.offsetX / target.clientWidth : 0; 370 const audioId = this.$queue.value?.now()?.id; 371 372 if (audioId) this.$audio.value?.seek({ audioId, percentage }); 373 }; 374 375 /** 376 * @param {MouseEvent} event 377 */ 378 setVolume = (event) => { 379 const target = event.target 380 ? /** @type {HTMLProgressElement} */ (event.target) 381 : null; 382 383 const percentage = target ? event.offsetX / target.clientWidth : 0; 384 this.$audio.value?.adjustVolume({ volume: percentage }); 385 }; 386 387 toggleFavourite = () => { 388 const track = this.currentTrack(); 389 if (!track) return; 390 391 this.$favourites.value?.toggle(track); 392 }; 393 394 // RENDER 395 396 /** 397 * @param {RenderArg} _ 398 */ 399 render({ html }) { 400 const activeQueueItem = this.currentTrack(); 401 const isFav = activeQueueItem 402 ? this.$favourites.value?.isFavourite(activeQueueItem) ?? false 403 : false; 404 405 // Artwork 406 const artworkArr = [ 407 this.#artwork.value.previous, 408 this.#artwork.value.current, 409 ].sort((a, b) => { 410 if (!a || !b) return 0; 411 return a.index % 2 ? 1 : -1; 412 }); 413 414 const artwork = artworkArr.map((art) => { 415 if (art === null) { 416 return null; 417 } 418 419 return cache(html` 420 <img 421 @load="${this.artworkLoaded}" 422 data-hash="${art.hash}" 423 src="${art.url}" 424 style="opacity: ${art.loaded ? `1` : `0`}" 425 /> 426 `); 427 }); 428 429 return html` 430 <link rel="stylesheet" href="vendor/@phosphor-icons/web/bold/style.css" /> 431 <link rel="stylesheet" href="vendor/@phosphor-icons/web/fill/style.css" /> 432 <link rel="stylesheet" href="styles/animations.css" /> 433 <link rel="stylesheet" href="themes/blur/artwork-controller/element.css" /> 434 435 <main style="background-color: ${this.#artworkColor.value ?? 436 `var(--color-3)`}; opacity: 0;"> 437 <section class="artwork"> 438 <label style="display: ${this.group === DEFAULT_GROUP 439 ? `none` 440 : `block`};"> 441 ${this.group} 442 </label> 443 444 ${artwork} 445 </section> 446 447 <section class="controller"> 448 <div class="gradient-blur"> 449 <div></div> 450 <div></div> 451 <div></div> 452 <div></div> 453 <div></div> 454 <div></div> 455 <div></div> 456 <div></div> 457 </div> 458 459 <div 460 class="controller__background" 461 style="background-color: ${this.#artworkColor.value ?? 462 `transparent`};" 463 > 464 </div> 465 466 <section class="controller__inner"> 467 <!-- NOW PLAYING --> 468 469 <cite> 470 <strong>${activeQueueItem?.tags?.title || 471 "Diffuse"}</strong> 472 <span style="font-style: ${activeQueueItem 473 ? `normal` 474 : `italic`}"> 475 ${activeQueueItem?.tags?.artist ?? 476 (activeQueueItem ? `` : `Waiting on queue ...`)} 477 </span> 478 </cite> 479 480 <!-- PROGRESS --> 481 482 <div class="progress" @click="${this.seek}"> 483 <progress max="100" value="${(this.audio()?.loadingState() === "loaded" ? (this.audio()?.progress() ?? 0) : 0) * 100}"></progress> 484 <div class="timestamps"> 485 <time datetime="${this.#time.value}">${this.#time.value}</time> 486 <time datetime="${this.#time.value}">${this.#duration 487 .value}</time> 488 </div> 489 </div> 490 491 <!-- CONTROLS --> 492 493 <menu> 494 <!-- previous --> 495 <li @click="${this.previous}"> 496 <i class="ph-fill ph-rewind" title="Previous track"></i> 497 </li> 498 499 <!-- loading ... --> 500 <div 501 class="animate-bounce menu__loader" 502 style="display: ${this.#isLoading.value ? `inherit` : `none`};" 503 > 504 <i class="ph-fill ph-vinyl-record" title="Loading ..."></i> 505 </div> 506 507 <!-- play --> 508 <li 509 @click="${this.playPause}" 510 style="display: ${!this.#isLoading.value && 511 !this.isPlaying() 512 ? `inline` 513 : `none`};" 514 > 515 <i class="ph-fill ph-play" title="Play"></i> 516 </li> 517 518 <!-- pause --> 519 <li 520 @click="${this.playPause}" 521 style="display: ${!this.#isLoading.value && this.isPlaying() 522 ? `inline` 523 : `none`};" 524 > 525 <i class="ph-fill ph-pause" title="Pause"></i> 526 </li> 527 528 <!-- next --> 529 <li @click="${this.next}"> 530 <i class="ph-fill ph-fast-forward" title="Next track"></i> 531 </li> 532 </menu> 533 534 <!-- VOLUME --> 535 536 <div class="volume"> 537 <i @click="${this.mute}" class="ph-fill ph-speaker-none"></i> 538 <div @click="${this.setVolume}" class="progress-bar"> 539 <progress max="100" value="${(this.$audio.value?.volume() ?? 540 0) * 100}"></progress> 541 </div> 542 <i @click="${this 543 .fullVolume}" class="ph-fill ph-speaker-high"></i> 544 </div> 545 546 <footer> 547 <div class="button-row"> 548 <button 549 title="Toggle favourite" 550 data-enabled="${isFav ? `t` : `f`}" 551 @click="${this.toggleFavourite}" 552 > 553 <i class="ph-${isFav ? `fill` : `bold`} ph-star"></i> 554 </button> 555 </div> 556 </footer> 557 </section> 558 </section> 559 </main> 560 `; 561 } 562} 563 564export default ArtworkController; 565 566//////////////////////////////////////////// 567// REGISTER 568//////////////////////////////////////////// 569 570export const CLASS = ArtworkController; 571export const NAME = "db-artwork-controller"; 572 573customElements.define(NAME, CLASS);