tangled
alpha
login
or
join now
vielle.dev
/
site
0
fork
atom
Personal Site
0
fork
atom
overview
issues
pulls
pipelines
Re add the javascript update logic
vielle.dev
7 months ago
98f28fc2
bf6f3ac5
verified
This commit was signed with the committer's
known signature
.
vielle.dev
SSH Key Fingerprint:
SHA256:/4bvxqoEh9iMdjAPgcgAgXKZZQTROL3ULiPt6nH9RSs=
+343
1 changed file
expand all
collapse all
unified
split
src
components
home
playing
NowPlaying.astro
+343
src/components/home/playing/NowPlaying.astro
···
344
}
345
}
346
</style>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
344
}
345
}
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>