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