Personal Site

Re add the javascript update logic

vielle.dev 98f28fc2 bf6f3ac5

verified
+343
+343
src/components/home/playing/NowPlaying.astro
··· 344 344 } 345 345 } 346 346 </style> 347 + <script> 348 + /*********** 349 + * IMPORTS * 350 + ***********/ 351 + 352 + import { isNowPlaying, type nowPlaying } from "./spotify/client"; 353 + 354 + /************* 355 + * FUNCTIONS * 356 + *************/ 357 + 358 + // utility 359 + function elIs<T extends typeof Element>( 360 + el: Element | null, 361 + is: T, 362 + name?: string, 363 + ): T["prototype"] { 364 + if (!(el instanceof is)) 365 + throw new Error( 366 + (name ? name : "Node") + 367 + " did not match type " + 368 + is.name + 369 + ".\nFound type " + 370 + el, 371 + ); 372 + return el; 373 + } 374 + 375 + function querySelector<T extends typeof Element>( 376 + parent: Element, 377 + selector: string, 378 + is: T, 379 + ): T["prototype"]; 380 + function querySelector<T extends typeof Element>( 381 + selector: string, 382 + is: T, 383 + ): T["prototype"]; 384 + function querySelector<T extends typeof Element>( 385 + el: Element | null, 386 + is: T, 387 + name?: string, 388 + ): T["prototype"]; 389 + function querySelector<T extends typeof Element>( 390 + arg1: Element | string | null, 391 + arg2: T | string, 392 + arg3?: T | string, 393 + ): T["prototype"] { 394 + const element = 395 + arg1 instanceof Element && typeof arg2 === "string" 396 + ? arg1.querySelector(arg2) 397 + : typeof arg1 === "string" 398 + ? document.querySelector(arg1) 399 + : arg1; 400 + 401 + const is = typeof arg2 !== "string" ? arg2 : arg3; 402 + if (!is || typeof is === "string") 403 + throw new Error("parameter is: invalid value: " + is); 404 + 405 + const name = 406 + typeof arg3 === "string" 407 + ? arg3 408 + : typeof arg2 === "string" 409 + ? arg2 410 + : typeof arg1 === "string" 411 + ? arg1 412 + : undefined; 413 + 414 + return elIs<T>(element, is, name); 415 + } 416 + 417 + function querySelectorAll<T extends typeof Element>( 418 + parent: Element, 419 + selector: string, 420 + is: T, 421 + ): NodeListOf<T["prototype"]>; 422 + function querySelectorAll<T extends typeof Element>( 423 + selector: string, 424 + is: T, 425 + ): NodeListOf<T["prototype"]>; 426 + function querySelectorAll<T extends typeof Element>( 427 + el: NodeListOf<Element>, 428 + is: T, 429 + name?: string, 430 + ): NodeListOf<T["prototype"]>; 431 + function querySelectorAll<T extends typeof Element>( 432 + arg1: Element | string | NodeListOf<Element>, 433 + arg2: T | string, 434 + arg3?: T | string, 435 + ): NodeListOf<T["prototype"]> { 436 + const nodeList = 437 + typeof arg1 === "string" 438 + ? document.querySelectorAll(arg1) 439 + : arg1 instanceof NodeList 440 + ? arg1 441 + : arg1.querySelectorAll( 442 + typeof arg2 === "string" ? arg2 : (undefined as never), 443 + ); 444 + 445 + const is = typeof arg2 !== "string" ? arg2 : arg3; 446 + if (!is || typeof is === "string") 447 + throw new Error("parameter is: invalid value: " + is); 448 + 449 + const name = 450 + typeof arg3 === "string" 451 + ? arg3 452 + : typeof arg2 === "string" 453 + ? arg2 454 + : typeof arg1 === "string" 455 + ? arg1 456 + : undefined; 457 + 458 + nodeList.forEach((el) => elIs(el, is, name)); 459 + 460 + return nodeList; 461 + } 462 + 463 + /************************* 464 + * HTMLNowPlayingElement * 465 + *************************/ 466 + 467 + class HTMLNowPlayingElement extends HTMLElement { 468 + // load elements and throw if wrong type 469 + elements = { 470 + title: querySelector(this, "[slot=title]", HTMLAnchorElement), 471 + album: querySelector(this, "[slot=album]", HTMLSpanElement), 472 + artists: querySelector(this, "[slot=artists]", HTMLSpanElement), 473 + art: querySelector(this, "[slot=art]", HTMLImageElement), 474 + }; 475 + 476 + updateMetadata(playing: Exclude<nowPlaying, null>) { 477 + // title can be updated without distrupting focus 478 + this.elements.title.innerText = playing.name; 479 + this.elements.title.href = playing.href; 480 + 481 + // same for album 482 + this.elements.album.innerText = playing.album; 483 + 484 + // same for art 485 + this.elements.art.src = playing.art; 486 + 487 + const artistLen = this.elements.artists.children.length; 488 + 489 + // artists is more complex, as focus needs to be maintained. 490 + const replaceArtists = playing.artists.slice(0, artistLen); 491 + // slice uses array.length if end is >= array.length 492 + // so we need to padd it out 493 + replaceArtists.push( 494 + ...new Array(artistLen - replaceArtists.length).fill(undefined), 495 + ); 496 + const addArtists = playing.artists.slice(artistLen); 497 + 498 + let lastValidArtist = elIs( 499 + this.elements.artists.children[0], 500 + HTMLElement, 501 + "artist", 502 + ); 503 + replaceArtists.forEach((artist, i) => { 504 + // if this index exists in both arrays, update in place 505 + // this respects focus and shouldnt cause issues 506 + const el = elIs(this.elements.artists.children[i], HTMLAnchorElement); 507 + if (artist) { 508 + el.innerHTML = artist.name; 509 + el.href = artist.href; 510 + // update last valid for moving focus too if needed 511 + lastValidArtist = el; 512 + } 513 + 514 + // if index exists in old array but not new array 515 + if (!artist) { 516 + if (document.activeElement === el) lastValidArtist.focus(); 517 + // essentially destroy it 518 + el.remove(); 519 + } 520 + }); 521 + 522 + // this is safe to stick direct in DOM 523 + // this is when the new artist count > old artist count 524 + this.elements.artists.append( 525 + ...addArtists.map((artist) => { 526 + const a = document.createElement("a"); 527 + 528 + a.innerText = artist.name; 529 + a.href = artist.href; 530 + 531 + return a; 532 + }), 533 + ); 534 + 535 + // remove the inline display value/render as we are handling it via inline styles now 536 + this.style.removeProperty("display"); 537 + delete this.dataset.render; 538 + } 539 + 540 + nothingPlaying() { 541 + // dont let it show up if nothing is playing 542 + this.style.setProperty("display", "none"); 543 + } 544 + } 545 + customElements.define("now-playing", HTMLNowPlayingElement); 546 + 547 + /************ 548 + * ELEMENTS * 549 + ************/ 550 + 551 + const elements = { 552 + spinner: Array.from(querySelectorAll(".player .spinner", HTMLDivElement)), 553 + recordArt: querySelector(".record .art", HTMLImageElement), 554 + nowPlaying: querySelector("now-playing", HTMLNowPlayingElement), 555 + player: querySelector(".player", HTMLElement), 556 + }; 557 + 558 + if (elements.spinner.length !== 2) 559 + throw new Error("Must have 2 `.spinner` elements!"); 560 + 561 + /************** 562 + * ANIMATIONS * 563 + **************/ 564 + 565 + // delete css animations since we r going to use our own 566 + elements.spinner.forEach((el) => 567 + el.getAnimations().forEach((anim) => anim.cancel()), 568 + ); 569 + 570 + const playHeadAnimation = [ 571 + { 572 + rotate: "0deg", 573 + }, 574 + { 575 + rotate: "25deg", 576 + offset: 0.05, 577 + }, 578 + { 579 + rotate: "45deg", 580 + offset: 0.7, 581 + }, 582 + { 583 + rotate: "45deg", 584 + offset: 0.75, 585 + }, 586 + { 587 + rotate: "0deg", 588 + offset: 0.8, 589 + }, 590 + ]; 591 + 592 + // start state is infered 593 + const goToStartAnimation = [{ rotate: "0deg" }]; 594 + 595 + const animations = elements.spinner.map((el) => 596 + el.animate(playHeadAnimation, { 597 + duration: 30 * 1000, 598 + fill: "forwards", 599 + iterations: Infinity, 600 + }), 601 + ); 602 + 603 + if (elements.player.dataset.playing === "false") { 604 + // dont play animations 605 + animations.forEach((anim) => anim.pause()); 606 + } 607 + 608 + /************ 609 + * LISTENER * 610 + ************/ 611 + 612 + let prev: nowPlaying = null; 613 + 614 + const ev = new EventSource("/now-playing-sse"); 615 + 616 + // close event source safely 617 + window.addEventListener("beforeunload", () => ev.close()); 618 + 619 + let i = -1; 620 + ev.addEventListener("playing", (event) => { 621 + i++; 622 + const data = (() => { 623 + try { 624 + return JSON.parse(event.data); 625 + } catch (e) { 626 + return e; 627 + } 628 + })(); 629 + if (!isNowPlaying(data)) 630 + return console.warn("Unexpected package from server:", data, event.data); 631 + 632 + // data is valid nowPlayingData 633 + try { 634 + if ( 635 + !( 636 + // first call so assume prev is in an invalid state (which it is) 637 + ( 638 + i === 0 || 639 + // if both are null, quit since re-rendering is pointless 640 + data == prev || 641 + // now if both are valid play items, so check the ID for equality 642 + (data !== null && prev !== null && data.id === prev.id) 643 + ) 644 + ) 645 + ) { 646 + // there is now a difference between the previous setting and the new setting 647 + // so it is worth updating the UI 648 + 649 + // spinner head animation: 650 + // 1. pause current animation. 651 + animations.forEach((anim) => anim.pause()); 652 + elements.spinner.forEach((el) => 653 + // 2. send the playback head to the start 654 + el 655 + .animate(goToStartAnimation, { 656 + duration: 2.5 * 1000, 657 + easing: "ease-in-out", 658 + }) 659 + // 3. when the playback head is at the start 660 + .finished.then(async () => { 661 + // 4. update the record art 662 + elements.recordArt.src = data 663 + ? data?.art 664 + : "https://undefined"; 665 + 666 + // 5. update popup 667 + if (data) elements.nowPlaying.updateMetadata(data); 668 + else elements.nowPlaying.nothingPlaying(); 669 + 670 + // 6. reset the position of the infinite animation 671 + animations.forEach((anim) => (anim.currentTime = 0)); 672 + 673 + // 7. if new track is not null then, after 2s 674 + if (data) 675 + setTimeout(() => { 676 + // 8. resume the infinite animation 677 + animations.forEach((anim) => anim.play()); 678 + }, 2000); 679 + 680 + // 9. make sure the record is in the right state (playing or paused) 681 + elements.player.dataset.playing = data ? "true" : "false"; 682 + }), 683 + ); 684 + } 685 + } finally { 686 + prev = data; 687 + } 688 + }); 689 + </script>