Personal Site

Completely gut NowPlaying for now. Content will be re-added when im happy with the API working properly. This is because I'm going to completely change the API signature

vielle.dev 79e93e30 4ea096b3

verified
+1 -704
+1 -704
src/components/home/playing/NowPlaying.astro
··· 1 1 --- 2 - import { spotifyNowPlaying, SpotifyError } from "./spotify"; 3 - 4 - import mp3Base from "/assets/mp3/base.png"; 5 - import mp3AlbumArtMask from "/assets/mp3/album-art-mask.png"; 6 - import mp3RecordCircle from "/assets/mp3/record-circle.png"; 7 - import mp3HeadCircle from "/assets/mp3/head-circle.png"; 8 - import mp3PlaybackHead from "/assets/mp3/playback-head.png"; 9 - import boxTlbr from "/assets/box-tlbr.png"; 10 - import popoutSpeech from "/assets/popout-speech.png"; 11 - import smallBoxMask from "/assets/small-box-mask.png"; 2 + import "./spotify" 12 3 13 - const track = await spotifyNowPlaying().catch((err) => { 14 - if (!(err instanceof SpotifyError)) throw new Error("Unhandled exception"); 15 - if (err.code === "NO_CONTENT") return null; 16 - 17 - console.error("NowPlaying.astro:", err.code, err.human, err.details); 18 - return err; 19 - }); 20 - 21 - if (track instanceof SpotifyError) 22 - console.error("NowPlaying.astro:", "Encountered spotify error:", track); 23 - 24 - const dataTrack = ( 25 - t: typeof track, 26 - ): t is Exclude<typeof track, SpotifyError | null> => 27 - !(t instanceof SpotifyError || !t); 28 4 --- 29 - 30 - { 31 - track instanceof SpotifyError && ( 32 - <script 33 - set:html={`console.error("Failed to load nowPlaying. See server console for reason.")`} 34 - /> 35 - ) 36 - } 37 - 38 - <section 39 - class="playing" 40 - id="now-playing" 41 - style={` 42 - --mp3-base-png: url(${mp3Base.src}); 43 - --mp3-album-art-mask-png: url(${mp3AlbumArtMask.src}); 44 - --mp3-record-circle-png: url(${mp3RecordCircle.src}); 45 - --mp3-head-circle-png: url(${mp3HeadCircle.src}); 46 - --mp3-playback-head-png: url(${mp3PlaybackHead.src}); 47 - --box-tlbr-png: url("${boxTlbr.src}"); 48 - --popout-speech-png: url("${popoutSpeech.src}"); 49 - --small-box-mask-png: url("${smallBoxMask.src}"); 50 - `} 51 - > 52 - <div 53 - class="player" 54 - tabindex="0" 55 - aria-label="Record player" 56 - data-playing={dataTrack(track) ? "true" : "false"} 57 - > 58 - <div class="spinner"></div> 59 - 60 - <div class="record"> 61 - <img 62 - src={!dataTrack(track) 63 - ? "https://undefined/" 64 - : track.album.images[0].url} 65 - alt="" 66 - class="art" 67 - /> 68 - </div> 69 - 70 - <div class="spinner hidden"> 71 - <div class="head"></div> 72 - </div> 73 - 74 - <!-- aria-live=off means aria updates are only announced when focused. 75 - this makes sense as what im listening too is irrelevant if --> 76 - <now-playing data-render={dataTrack(track)} aria-live="off"> 77 - <a 78 - slot="title" 79 - href={dataTrack(track) ? track.external_urls.spotify : "#"} 80 - > 81 - {dataTrack(track) ? track.name : null} 82 - </a> 83 - <span slot="album">{dataTrack(track) ? track.album.name : null}</span> 84 - <span slot="artists"> 85 - { 86 - dataTrack(track) ? ( 87 - track.artists 88 - .map((artist) => ( 89 - <a href={artist.external_urls.spotify}>{artist.name}</a> 90 - )) 91 - // inject a comma before each entry in the list except the first one 92 - // flatmap flattens the returned array into the new map 93 - .flatMap((x, i) => (i === 0 ? x : [", ", x])) 94 - ) : ( 95 - // artist defined by default because 96 - // i cant be bothered to do client error handling 97 - // and this is easier 98 - <a href="#">Artist Name</a> 99 - ) 100 - } 101 - </span> 102 - <img 103 - slot="art" 104 - src={!dataTrack(track) 105 - ? "https://undefined/" 106 - : track.album.images[0].url} 107 - alt="" 108 - /> 109 - 110 - <template shadowrootmode="open" shadowrootdelegatesfocus> 111 - <div class="layout"> 112 - <span class="name"> 113 - <slot is:inline name="title" /> 114 - (<slot is:inline name="album" />) 115 - </span> 116 - <span class="artists"> 117 - <slot is:inline name="artists" /> 118 - </span> 119 - <div class="art"> 120 - <slot is:inline name="art" /> 121 - </div> 122 - </div> 123 - 124 - <style> 125 - /* dont show element if it errored or nothing is playing */ 126 - :host([data-render="false"]) { 127 - display: none !important; 128 - } 129 - 130 - :host { 131 - contain: layout; 132 - position: absolute; 133 - bottom: 105cqh; 134 - 135 - width: 100cqw; 136 - height: auto; 137 - 138 - box-sizing: border-box; 139 - /* gets overridden by the fukcin * selector. for some reason */ 140 - padding: calc((25 / 3) * 1cqw) !important; 141 - 142 - border-image: var(--box-tlbr-png) 10 fill / calc((20 / 3) * 1cqw) 143 - round; 144 - 145 - &::after { 146 - content: ""; 147 - 148 - position: absolute; 149 - bottom: calc((-20 / 3) * 1cqw); 150 - left: 0; 151 - 152 - /* width 100% catches :hover to stop it disapearing. also makes centering less magic numbery */ 153 - width: 100cqw; 154 - height: calc((40 / 3) * 1cqw); 155 - 156 - background-image: var(--popout-speech-png); 157 - background-size: contain; 158 - background-position: center; 159 - background-repeat: no-repeat; 160 - } 161 - } 162 - 163 - .layout { 164 - display: grid; 165 - grid-template: 166 - "name art" auto 167 - "artists art" auto 168 - / 1fr 80px; 169 - align-items: center; 170 - justify-content: center; 171 - 172 - .name { 173 - grid-area: name; 174 - align-self: end; 175 - } 176 - 177 - .artists { 178 - grid-area: artists; 179 - align-self: start; 180 - } 181 - 182 - .art { 183 - grid-area: art; 184 - width: 80px; 185 - height: 80ps; 186 - } 187 - } 188 - </style> 189 - </template> 190 - </now-playing> 191 - </div> 192 - </section> 193 - 194 - <style> 195 - @keyframes spin { 196 - from { 197 - rotate: 0deg; 198 - } 199 - 200 - to { 201 - rotate: 360deg; 202 - } 203 - } 204 - 205 - @keyframes head-move { 206 - from, 207 - 80%, 208 - to { 209 - rotate: 0deg; 210 - } 211 - 212 - 2.5% { 213 - rotate: 25deg; 214 - } 215 - 216 - 70%, 217 - 75% { 218 - rotate: 45deg; 219 - } 220 - } 221 - 222 - .player { 223 - /* internal and external nodes dont affect each other 224 - children dont affect this size 225 - properties dont affect external nodes */ 226 - contain: layout size style; 227 - container: player / size; 228 - width: 100%; 229 - /* design size is 300px by 244px 230 - treat 1cqw = 3px. 231 - */ 232 - aspect-ratio: 300/244; 233 - 234 - image-rendering: pixelated; 235 - background-image: var(--mp3-base-png); 236 - background-size: contain; 237 - 238 - * { 239 - background-size: contain; 240 - } 241 - 242 - position: relative; 243 - 244 - & .record { 245 - position: absolute; 246 - top: calc((20 / 3) * 1cqw); 247 - left: calc((40 / 3) * 1cqw); 248 - 249 - width: calc((200 / 3) * 1cqw); 250 - height: calc((200 / 3) * 1cqw); 251 - background-image: var(--mp3-record-circle-png); 252 - 253 - animation: 30s linear forwards infinite spin; 254 - 255 - [data-playing="false"] & { 256 - animation-play-state: paused; 257 - } 258 - 259 - & .art { 260 - position: absolute; 261 - top: calc((50 / 3) * 1cqw); 262 - left: calc((50 / 3) * 1cqw); 263 - width: calc((100 / 3) * 1cqw); 264 - height: calc((100 / 3) * 1cqw); 265 - max-width: none; 266 - background-color: #008282; 267 - 268 - mask-image: var(--mp3-album-art-mask-png); 269 - mask-size: calc((100 / 3) * 1cqw) calc((100 / 3) * 1cqw); 270 - } 271 - } 272 - 273 - & .spinner { 274 - position: absolute; 275 - top: calc((30 / 3) * 1cqw); 276 - left: calc((214 / 3) * 1cqw); 277 - 278 - background-image: var(--mp3-head-circle-png); 279 - width: calc((60 / 3) * 1cqw); 280 - height: calc((60 / 3) * 1cqw); 281 - 282 - animation: 60s linear 2.5s infinite forwards head-move; 283 - 284 - [data-playing="false"] & { 285 - animation-play-state: paused; 286 - } 287 - 288 - &.hidden { 289 - background: none; 290 - } 291 - 292 - & .head { 293 - position: absolute; 294 - top: calc((-10 / 3) * 1cqw); 295 - left: calc((28 / 3) * 1cqw); 296 - width: calc((30 / 3) * 1cqw); 297 - height: calc((200 / 3) * 1cqw); 298 - background-image: var(--mp3-playback-head-png); 299 - } 300 - } 301 - } 302 - 303 - now-playing { 304 - display: none; 305 - } 306 - 307 - /* setup the ::before to be usable for the outline 308 - filter cannot be applied straight to .player 309 - as the now-playing is a child and also gets the outline 310 - which we dont want */ 311 - .player::before { 312 - content: ""; 313 - 314 - width: 100cqw; 315 - height: 100cqh; 316 - 317 - position: absolute; 318 - top: 0; 319 - left: 0; 320 - 321 - background-image: var(--mp3-base-png); 322 - background-size: contain; 323 - } 324 - 325 - .player:focus, 326 - .player:focus-within { 327 - outline: none; 328 - --outline-colour: #c274d1; 329 - --outline-size: 4px; 330 - 331 - &:focus::before { 332 - /* filter is used instead of a standard property 333 - as it means we can match it to the shape of the custom outline */ 334 - filter: drop-shadow(var(--outline-colour) 0 var(--outline-size)) 335 - drop-shadow(var(--outline-colour) var(--outline-size) 0) 336 - drop-shadow(var(--outline-colour) 0 calc(-1 * var(--outline-size))) 337 - drop-shadow(var(--outline-colour) calc(-1 * var(--outline-size)) 0); 338 - } 339 - 340 - & now-playing { 341 - display: block; 342 - } 343 - } 344 - 345 - now-playing { 346 - & img { 347 - mask-image: var(--small-box-mask-png); 348 - mask-size: contain; 349 - } 350 - & a { 351 - color: black; 352 - 353 - &:focus, 354 - &:hover { 355 - text-decoration-style: dashed; 356 - } 357 - 358 - &:active { 359 - text-decoration: none; 360 - } 361 - } 362 - } 363 - </style> 364 - 365 - <script> 366 - /*********** 367 - * IMPORTS * 368 - ***********/ 369 - 370 - import { isNowPlaying, type nowPlaying } from "./spotify/client"; 371 - 372 - /************* 373 - * FUNCTIONS * 374 - *************/ 375 - 376 - // utility 377 - function elIs<T extends typeof Element>( 378 - el: Element | null, 379 - is: T, 380 - name?: string, 381 - ): T["prototype"] { 382 - if (!(el instanceof is)) 383 - throw new Error( 384 - (name ? name : "Node") + 385 - " did not match type " + 386 - is.name + 387 - ".\nFound type " + 388 - el, 389 - ); 390 - return el; 391 - } 392 - 393 - function querySelector<T extends typeof Element>( 394 - parent: Element, 395 - selector: string, 396 - is: T, 397 - ): T["prototype"]; 398 - function querySelector<T extends typeof Element>( 399 - selector: string, 400 - is: T, 401 - ): T["prototype"]; 402 - function querySelector<T extends typeof Element>( 403 - el: Element | null, 404 - is: T, 405 - name?: string, 406 - ): T["prototype"]; 407 - function querySelector<T extends typeof Element>( 408 - arg1: Element | string | null, 409 - arg2: T | string, 410 - arg3?: T | string, 411 - ): T["prototype"] { 412 - const element = 413 - arg1 instanceof Element && typeof arg2 === "string" 414 - ? arg1.querySelector(arg2) 415 - : typeof arg1 === "string" 416 - ? document.querySelector(arg1) 417 - : arg1; 418 - 419 - const is = typeof arg2 !== "string" ? arg2 : arg3; 420 - if (!is || typeof is === "string") 421 - throw new Error("parameter is: invalid value: " + is); 422 - 423 - const name = 424 - typeof arg3 === "string" 425 - ? arg3 426 - : typeof arg2 === "string" 427 - ? arg2 428 - : typeof arg1 === "string" 429 - ? arg1 430 - : undefined; 431 - 432 - return elIs<T>(element, is, name); 433 - } 434 - 435 - function querySelectorAll<T extends typeof Element>( 436 - parent: Element, 437 - selector: string, 438 - is: T, 439 - ): NodeListOf<T["prototype"]>; 440 - function querySelectorAll<T extends typeof Element>( 441 - selector: string, 442 - is: T, 443 - ): NodeListOf<T["prototype"]>; 444 - function querySelectorAll<T extends typeof Element>( 445 - el: NodeListOf<Element>, 446 - is: T, 447 - name?: string, 448 - ): NodeListOf<T["prototype"]>; 449 - function querySelectorAll<T extends typeof Element>( 450 - arg1: Element | string | NodeListOf<Element>, 451 - arg2: T | string, 452 - arg3?: T | string, 453 - ): NodeListOf<T["prototype"]> { 454 - const nodeList = 455 - typeof arg1 === "string" 456 - ? document.querySelectorAll(arg1) 457 - : arg1 instanceof NodeList 458 - ? arg1 459 - : arg1.querySelectorAll( 460 - typeof arg2 === "string" ? arg2 : (undefined as never), 461 - ); 462 - 463 - const is = typeof arg2 !== "string" ? arg2 : arg3; 464 - if (!is || typeof is === "string") 465 - throw new Error("parameter is: invalid value: " + is); 466 - 467 - const name = 468 - typeof arg3 === "string" 469 - ? arg3 470 - : typeof arg2 === "string" 471 - ? arg2 472 - : typeof arg1 === "string" 473 - ? arg1 474 - : undefined; 475 - 476 - nodeList.forEach((el) => elIs(el, is, name)); 477 - 478 - return nodeList; 479 - } 480 - 481 - /************************* 482 - * HTMLNowPlayingElement * 483 - *************************/ 484 - 485 - class HTMLNowPlayingElement extends HTMLElement { 486 - // load elements and throw if wrong type 487 - elements = { 488 - title: querySelector(this, "[slot=title]", HTMLAnchorElement), 489 - album: querySelector(this, "[slot=album]", HTMLSpanElement), 490 - artists: querySelector(this, "[slot=artists]", HTMLSpanElement), 491 - art: querySelector(this, "[slot=art]", HTMLImageElement), 492 - }; 493 - 494 - updateMetadata(playing: Exclude<nowPlaying, null>) { 495 - // title can be updated without distrupting focus 496 - this.elements.title.innerText = playing.name; 497 - this.elements.title.href = playing.external_urls.spotify; 498 - 499 - // same for album 500 - this.elements.album.innerText = playing.album.name; 501 - 502 - // same for art 503 - this.elements.art.src = playing.album.images[0].url; 504 - 505 - const artistLen = this.elements.artists.children.length; 506 - 507 - // artists is more complex, as focus needs to be maintained. 508 - const replaceArtists = playing.artists.slice(0, artistLen); 509 - // slice uses array.length if end is >= array.length 510 - // so we need to padd it out 511 - replaceArtists.push( 512 - ...new Array(artistLen - replaceArtists.length).fill(undefined), 513 - ); 514 - const addArtists = playing.artists.slice(artistLen); 515 - 516 - let lastValidArtist = elIs( 517 - this.elements.artists.children[0], 518 - HTMLElement, 519 - "artist", 520 - ); 521 - replaceArtists.forEach((artist, i) => { 522 - // if this index exists in both arrays, update in place 523 - // this respects focus and shouldnt cause issues 524 - const el = elIs(this.elements.artists.children[i], HTMLAnchorElement); 525 - if (artist) { 526 - el.innerHTML = artist.name; 527 - el.href = artist.external_urls.spotify; 528 - // update last valid for moving focus too if needed 529 - lastValidArtist = el; 530 - } 531 - 532 - // if index exists in old array but not new array 533 - if (!artist) { 534 - if (document.activeElement === el) lastValidArtist.focus(); 535 - // essentially destroy it 536 - el.remove(); 537 - } 538 - }); 539 - 540 - // this is safe to stick direct in DOM 541 - // this is when the new artist count > old artist count 542 - this.elements.artists.append( 543 - ...addArtists.map((artist) => { 544 - const a = document.createElement("a"); 545 - 546 - a.innerText = artist.name; 547 - a.href = artist.external_urls.spotify; 548 - 549 - return a; 550 - }), 551 - ); 552 - 553 - // remove the inline display value/render as we are handling it via inline styles now 554 - this.style.removeProperty("display"); 555 - delete this.dataset.render; 556 - } 557 - 558 - nothingPlaying() { 559 - // dont let it show up if nothing is playing 560 - this.style.setProperty("display", "none"); 561 - } 562 - } 563 - customElements.define("now-playing", HTMLNowPlayingElement); 564 - 565 - /************ 566 - * ELEMENTS * 567 - ************/ 568 - 569 - const elements = { 570 - spinner: Array.from(querySelectorAll(".player .spinner", HTMLDivElement)), 571 - recordArt: querySelector(".record .art", HTMLImageElement), 572 - nowPlaying: querySelector("now-playing", HTMLNowPlayingElement), 573 - player: querySelector(".player", HTMLElement), 574 - }; 575 - 576 - if (elements.spinner.length !== 2) 577 - throw new Error("Must have 2 `.spinner` elements!"); 578 - 579 - /************** 580 - * ANIMATIONS * 581 - **************/ 582 - 583 - // delete css animations since we r going to use our own 584 - elements.spinner.forEach((el) => 585 - el.getAnimations().forEach((anim) => anim.cancel()), 586 - ); 587 - 588 - const playHeadAnimation = [ 589 - { 590 - rotate: "0deg", 591 - }, 592 - { 593 - rotate: "25deg", 594 - offset: 0.05, 595 - }, 596 - { 597 - rotate: "45deg", 598 - offset: 0.7, 599 - }, 600 - { 601 - rotate: "45deg", 602 - offset: 0.75, 603 - }, 604 - { 605 - rotate: "0deg", 606 - offset: 0.8, 607 - }, 608 - ]; 609 - 610 - // start state is infered 611 - const goToStartAnimation = [{ rotate: "0deg" }]; 612 - 613 - const animations = elements.spinner.map((el) => 614 - el.animate(playHeadAnimation, { 615 - duration: 30 * 1000, 616 - fill: "forwards", 617 - iterations: Infinity, 618 - }), 619 - ); 620 - 621 - if (elements.player.dataset.playing === "false") { 622 - // dont play animations 623 - animations.forEach((anim) => anim.pause()); 624 - } 625 - 626 - /************ 627 - * LISTENER * 628 - ************/ 629 - 630 - let prev: nowPlaying = null; 631 - 632 - const ev = new EventSource("/now-playing-sse"); 633 - 634 - // close event source safely 635 - window.addEventListener("beforeunload", () => ev.close()); 636 - 637 - let i = -1; 638 - ev.addEventListener("playing", (event) => { 639 - i++; 640 - const data = (() => { 641 - try { 642 - return JSON.parse(event.data); 643 - } catch (e) { 644 - return e; 645 - } 646 - })(); 647 - if (!isNowPlaying(data)) 648 - return console.warn("Unexpected package from server:", data, event.data); 649 - 650 - // data is valid nowPlayingData 651 - try { 652 - if ( 653 - !( 654 - // first call so assume prev is in an invalid state (which it is) 655 - ( 656 - i === 0 || 657 - // if both are null, quit since re-rendering is pointless 658 - data == prev || 659 - // now if both are valid play items, so check the ID for equality 660 - (data !== null && prev !== null && data.id === prev.id) 661 - ) 662 - ) 663 - ) { 664 - // there is now a difference between the previous setting and the new setting 665 - // so it is worth updating the UI 666 - 667 - // spinner head animation: 668 - // 1. pause current animation. 669 - animations.forEach((anim) => anim.pause()); 670 - elements.spinner.forEach((el) => 671 - // 2. send the playback head to the start 672 - el 673 - .animate(goToStartAnimation, { 674 - duration: 2.5 * 1000, 675 - easing: "ease-in-out", 676 - }) 677 - // 3. when the playback head is at the start 678 - .finished.then(async () => { 679 - // 4. update the record art 680 - elements.recordArt.src = data 681 - ? data?.album.images[0].url 682 - : "https://undefined"; 683 - 684 - // 5. update popup 685 - if (data) elements.nowPlaying.updateMetadata(data); 686 - else elements.nowPlaying.nothingPlaying(); 687 - 688 - // 6. reset the position of the infinite animation 689 - animations.forEach((anim) => (anim.currentTime = 0)); 690 - 691 - // 7. if new track is not null then, after 2s 692 - if (data) 693 - setTimeout(() => { 694 - // 8. resume the infinite animation 695 - animations.forEach((anim) => anim.play()); 696 - }, 2000); 697 - 698 - // 9. make sure the record is in the right state (playing or paused) 699 - elements.player.dataset.playing = data ? "true" : "false"; 700 - }), 701 - ); 702 - } 703 - } finally { 704 - prev = data; 705 - } 706 - }); 707 - </script>