Add minimised player option, remove transition on about image due to transition bug, make about image bigger
Build and Deploy to Web Server / deploy (push) Successful in 6m21s

This commit is contained in:
encie22 committed 2026-10-13 14:59:02 +10:30
1 parent a5849db65b
commit 20bd5bc654
3 files changed
+394 -71

No files matched your search

+392 -67
View File
@@ -39,20 +39,24 @@ const playerArtworkBlur = "10px";
const playerArtworkOpacity = 0.4; const playerArtworkOpacity = 0.4;
--- ---
<!-- Morphing player shell: bar <-> circle, layers crossfade inside -->
<div <div
id="player" id="player-shell"
class:list={[ class:list={[
"fixed right-0 bottom-0 left-0 z-100 overflow-hidden bg-black shadow-lg/75 md:right-3 md:bottom-3 md:left-3 md:rounded", "group fixed z-100 overflow-hidden bg-black shadow-lg/75",
height height
]} ]}
transition:persist="" transition:persist=""
transition:name="player" transition:name="player"
transition:animate="none" transition:animate="none"
> >
<!-- Full-bar layer -->
<div class="player-full absolute inset-0">
<!-- Blurred artwork backdrop (static: animations restart on view transitions) -->
<div class="absolute inset-0 -z-90 overflow-hidden"> <div class="absolute inset-0 -z-90 overflow-hidden">
<div <div
id="player:artwork:background" id="player:artwork:background"
class="animate-bg-perlin absolute top-0 right-0 bottom-0 left-0 -z-90" class="absolute top-0 right-0 bottom-0 left-0 -z-90"
> >
</div> </div>
</div> </div>
@@ -77,11 +81,24 @@ const playerArtworkOpacity = 0.4;
</div> </div>
</div> </div>
<div class="px-2 pt-2 pb-2 text-white md:px-3 md:pb-3"> <div class="px-2 pt-2 pb-2 text-white md:px-3 md:pb-3">
<div class="grid grid-cols-2 text-xs"> <div class="grid grid-cols-[1fr_auto_1fr] items-center text-xs">
<span id="player:times:current" class="col-span-1 text-left">0:00</span> <span id="player:times:current" class="col-span-1 text-left">0:00</span>
<span id="player:times:duration" class="col-span-1 text-right">0:00</span> <button
id="player:controls:minimise"
type="button"
title="Minimise player"
aria-label="Minimise player"
class="col-span-1 rounded-full px-3 py-1 text-gray-400 hover:cursor-pointer hover:text-white"
>
<Icon name="fa7-solid:chevron-down" class="pointer-events-none" />
</button>
<span id="player:times:duration" class="col-span-1 text-right"
>0:00</span
>
</div> </div>
<div class="flex h-full flex-row items-center justify-center align-middle"> <div
class="flex h-full flex-row items-center justify-center align-middle"
>
<div class="m-0 flex grow-0 flex-row space-x-2 p-0 text-3xl"> <div class="m-0 flex grow-0 flex-row space-x-2 p-0 text-3xl">
<span> <span>
<Icon name="fa7-solid:backward" id="player:controls:previous" /> <Icon name="fa7-solid:backward" id="player:controls:previous" />
@@ -99,15 +116,173 @@ const playerArtworkOpacity = 0.4;
</span> </span>
</div> </div>
<div class="flex flex-grow flex-col text-sm"> <div class="flex flex-grow flex-col text-sm">
<span id="player:metadata:title" class="w-full text-right font-semibold" <span
></span> id="player:metadata:title"
class="w-full text-right font-semibold"></span>
<span id="player:metadata:subtitle" class="w-full text-right"></span> <span id="player:metadata:subtitle" class="w-full text-right"></span>
<span id="player:metadata:extra" class="w-full text-right"></span> <span id="player:metadata:extra" class="w-full text-right"></span>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Circle face: artwork + progress ring -->
<div class="player-face absolute inset-0" aria-hidden="true">
<div class="absolute inset-0 rounded-full bg-black ring-1 ring-white/10">
</div>
<img
id="player:face:artwork"
alt=""
class="absolute inset-[5px] h-[calc(100%-10px)] w-[calc(100%-10px)] rounded-full bg-gray-900 object-cover"
/>
<div
id="player:face:fallback"
class="absolute inset-[5px] flex items-center justify-center rounded-full bg-gray-900 text-xl text-gray-400"
>
<Icon name="fa7-solid:music" class="pointer-events-none" />
</div>
<svg
class="absolute inset-0 h-full w-full -rotate-90"
viewBox="0 0 64 64"
fill="none"
aria-hidden="true"
>
<circle
cx="32"
cy="32"
r="29"
stroke="rgba(255,255,255,0.15)"
stroke-width="4"></circle>
<circle
id="player:face:progress:buffered"
cx="32"
cy="32"
r="29"
stroke="rgba(255,255,255,0.35)"
stroke-width="4"
stroke-linecap="round"
stroke-dasharray="182.2"
stroke-dashoffset="182.2"></circle>
<circle
id="player:face:progress:played"
cx="32"
cy="32"
r="29"
stroke="#ff2d00"
stroke-width="4"
stroke-linecap="round"
stroke-dasharray="182.2"
stroke-dashoffset="182.2"></circle>
</svg>
<button
id="player:face:controls:toggle"
type="button"
title="Play / pause"
aria-label="Play / pause"
class="absolute top-1/2 left-1/2 flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white opacity-100 backdrop-blur-sm hover:cursor-pointer hover:bg-black/70 md:h-9 md:w-9 md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100"
>
<Icon
name="fa7-solid:play"
id="player:face:controls:play"
class="pointer-events-none text-sm drop-shadow"
/>
<Icon
name="fa7-solid:pause"
id="player:face:controls:pause"
class="pointer-events-none hidden text-sm drop-shadow"
/>
</button>
</div>
</div> </div>
<div class:list={[height, "bg-gray-900"]}></div> <div id="player:spacer" class:list={[height, "bg-gray-900"]}></div>
<style>
#player-shell {
--ease-slide: cubic-bezier(0.22, 1, 0.36, 1);
--ease-round: cubic-bezier(0.34, 1.56, 0.64, 1);
left: 0;
width: 100%;
bottom: 0;
border-radius: 0;
transition:
left 0.55s var(--ease-slide),
width 0.55s var(--ease-slide),
bottom 0.55s var(--ease-slide),
height 0.55s var(--ease-slide),
border-radius 0.6s var(--ease-round);
}
#player-shell.player--minimised {
left: calc(100% - 9rem);
width: 8rem;
bottom: 1rem;
height: 8rem;
border-radius: 4rem;
cursor: pointer;
}
@media (min-width: 768px) {
#player-shell {
left: 0.75rem;
width: calc(100% - 1.5rem);
bottom: 0.75rem;
border-radius: 0.25rem;
}
#player-shell.player--minimised {
left: calc(100% - 9.5rem);
width: 8rem;
bottom: 1.5rem;
height: 8rem;
}
}
#player\:spacer.player-spacer--hidden {
display: none;
}
/* Outgoing layer fades fast, incoming fades in late */
.player-full {
transition:
opacity 0.25s ease 0.3s,
visibility 0s linear 0s;
}
#player-shell.player--minimised .player-full {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 0.15s ease 0s,
visibility 0s linear 0.15s;
}
.player-face {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 0.15s ease 0s,
visibility 0s linear 0.15s;
}
#player-shell.player--minimised .player-face {
opacity: 1;
visibility: visible;
pointer-events: auto;
transition:
opacity 0.25s ease 0.3s,
visibility 0s linear 0.3s;
}
@media (prefers-reduced-motion: reduce) {
#player-shell,
.player-full,
.player-face {
transition: none;
}
}
/* No morph on first paint */
#player-shell.player--no-motion,
#player-shell.player--no-motion .player-full,
#player-shell.player--no-motion .player-face {
transition: none !important;
}
</style>
<script is:inline src="/js/howler.min.js"></script> <script is:inline src="/js/howler.min.js"></script>
<script <script
@@ -119,34 +294,57 @@ const playerArtworkOpacity = 0.4;
* Includes all methods for playing, skipping, updating the display, etc. * Includes all methods for playing, skipping, updating the display, etc.
* @param {Array} playlist Array of objects with playlist song details ({title, file, howl}). * @param {Array} playlist Array of objects with playlist song details ({title, file, howl}).
*/ */
const $ = (id) => document.getElementById(id);
class Player { class Player {
constructor(playlist) { constructor(playlist) {
this.playlist = window.player?.playlist ?? playlist; this.playlist = window.player?.playlist ?? playlist;
this.index = window.player?.index ?? 0; this.index = window.player?.index ?? 0;
this.minimised = window.player?.minimised ?? false;
this.RING_C = 182.2; // 2 * PI * 29, must match dasharray in markup
this.UI = { this.UI = {
root: $("player-shell"),
spacer: $("player:spacer"),
full: document.querySelector("#player-shell .player-full"),
face: document.querySelector("#player-shell .player-face"),
controls: { controls: {
play: document.getElementById("player:controls:play"), play: $("player:controls:play"),
pause: document.getElementById("player:controls:pause"), pause: $("player:controls:pause"),
previous: document.getElementById("player:controls:previous"), previous: $("player:controls:previous"),
next: document.getElementById("player:controls:next") next: $("player:controls:next"),
minimise: $("player:controls:minimise")
}, },
times: { times: {
current: document.getElementById("player:times:current"), current: $("player:times:current"),
duration: document.getElementById("player:times:duration") duration: $("player:times:duration")
}, },
metadata: { metadata: {
title: document.getElementById("player:metadata:title"), title: $("player:metadata:title"),
subtitle: document.getElementById("player:metadata:subtitle"), subtitle: $("player:metadata:subtitle"),
extra: document.getElementById("player:metadata:extra") extra: $("player:metadata:extra")
}, },
progress: { progress: {
bar: document.getElementById("player:time:progress"), bar: $("player:time:progress"),
buffered: document.getElementById("player:time:progress:buffered"), buffered: $("player:time:progress:buffered"),
played: document.getElementById("player:time:progress:played") played: $("player:time:progress:played")
}, },
artwork: { artwork: {
background: document.getElementById("player:artwork:background") background: $("player:artwork:background")
},
faceArtwork: {
img: $("player:face:artwork"),
fallback: $("player:face:fallback")
},
faceProgress: {
buffered: $("player:face:progress:buffered"),
played: $("player:face:progress:played")
},
faceControls: {
toggle: $("player:face:controls:toggle"),
play: $("player:face:controls:play"),
pause: $("player:face:controls:pause")
} }
}; };
@@ -158,38 +356,161 @@ const playerArtworkOpacity = 0.4;
Math.round(playlist[this.index].duration) Math.round(playlist[this.index].duration)
); );
if (playlist[this.index].metadata.artwork) { this.updateArtwork(playlist[this.index]);
this.UI.artwork.background.style.backgroundImage = `url('${playlist[this.index].metadata.artwork.src}')`; this.syncPlayPauseUI(false);
this.UI.artwork.background.style.backgroundSize = "cover";
this.UI.artwork.background.style.backgroundPosition = "center";
this.UI.artwork.background.style.filter = `blur(${playerArtworkBlur})`;
this.UI.artwork.background.style.opacity = playerArtworkOpacity;
} else {
this.UI.artwork.background.style.backgroundImage = "";
this.UI.artwork.background.style.filter = "";
this.UI.artwork.background.style.opacity = "0";
}
this.UI.controls.play.addEventListener("click", function () { try {
const stored = localStorage.getItem("player-minimised");
if (stored !== null && window.player === undefined) {
this.minimised = stored === "1";
}
} catch (e) {
/* ignore */
}
// No morph on first paint.
this.UI.root.classList.add("player--no-motion");
this.applyminimised();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.UI.root?.classList.remove("player--no-motion");
});
});
this.UI.controls.play.addEventListener("click", (e) => {
e.stopPropagation();
window.player.play(); window.player.play();
}); });
this.UI.controls.pause.addEventListener("click", function () { this.UI.controls.pause.addEventListener("click", (e) => {
e.stopPropagation();
window.player.pause(); window.player.pause();
}); });
this.UI.controls.previous.addEventListener("click", function () { this.UI.controls.previous.addEventListener("click", (e) => {
e.stopPropagation();
window.player.skip("prev"); window.player.skip("prev");
}); });
this.UI.controls.next.addEventListener("click", function () { this.UI.controls.next.addEventListener("click", (e) => {
e.stopPropagation();
window.player.skip("next"); window.player.skip("next");
}); });
this.UI.controls.minimise.addEventListener("click", (e) => {
e.stopPropagation();
window.player.setminimised(true);
});
this.UI.progress.bar.addEventListener("click", function (event) { this.UI.progress.bar.addEventListener("click", function (event) {
event.stopPropagation();
window.player.seek(event.clientX / window.innerWidth); window.player.seek(event.clientX / window.innerWidth);
}); });
// Tap the circle to expand (not the play button).
const expand = (e) => {
if (!window.player.minimised) return;
if (
e.target instanceof Element &&
this.UI.faceControls.toggle.contains(e.target)
) {
return;
}
if (e.type === "keydown" && e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
window.player.setminimised(false);
};
this.UI.root.addEventListener("click", expand);
this.UI.root.addEventListener("keydown", expand);
this.UI.faceControls.toggle.addEventListener("click", (e) => {
e.stopPropagation();
const sound = window.player.playlist[window.player.index].howl;
if (sound && sound.playing()) {
window.player.pause();
} else {
window.player.play();
}
});
window.playerIsInitialised = true; window.playerIsInitialised = true;
} }
setminimised(minimised) {
this.minimised = minimised;
try {
localStorage.setItem("player-minimised", minimised ? "1" : "0");
} catch (e) {
/* ignore */
}
this.applyminimised();
}
// One class drives the morph; re-applying is a no-op.
applyminimised() {
if (!this.UI.root) return;
if (this.minimised) {
this.UI.root.classList.add("player--minimised");
this.UI.root.setAttribute("role", "button");
this.UI.root.setAttribute("tabindex", "0");
this.UI.root.setAttribute(
"title",
"Expand player — click to restore full size"
);
this.UI.root.setAttribute(
"aria-label",
"Expand player — click to restore full size"
);
this.UI.spacer?.classList.add("player-spacer--hidden");
this.UI.full?.setAttribute("aria-hidden", "true");
this.UI.face?.setAttribute("aria-hidden", "false");
} else {
this.UI.root.classList.remove("player--minimised");
this.UI.root.removeAttribute("role");
this.UI.root.removeAttribute("tabindex");
this.UI.root.removeAttribute("title");
this.UI.root.removeAttribute("aria-label");
this.UI.spacer?.classList.remove("player-spacer--hidden");
this.UI.full?.removeAttribute("aria-hidden");
this.UI.face?.setAttribute("aria-hidden", "true");
}
}
updateArtwork(data) {
const artworkSrc = data.metadata.artwork?.src;
const bg = this.UI.artwork.background;
const img = this.UI.faceArtwork.img;
const fallback = this.UI.faceArtwork.fallback;
bg.style.backgroundImage = artworkSrc ? `url('${artworkSrc}')` : "";
bg.style.backgroundSize = "cover";
bg.style.backgroundPosition = "center";
bg.style.filter = artworkSrc ? `blur(${playerArtworkBlur})` : "";
bg.style.opacity = artworkSrc ? playerArtworkOpacity : "0";
if (!img) return;
if (artworkSrc) {
const alt =
data.metadata.artwork?.alt || data.metadata.title || "Track artwork";
img.src = artworkSrc;
img.style.display = "";
img.alt = typeof alt === "string" ? alt : "";
if (fallback) fallback.style.display = "none";
} else {
img.removeAttribute("src");
img.style.display = "none";
img.alt = "";
if (fallback) fallback.style.display = "";
}
}
syncPlayPauseUI(isPlaying) {
for (const set of [this.UI.controls, this.UI.faceControls]) {
set.pause.classList.toggle("hidden", !isPlaying);
set.play.classList.toggle("hidden", isPlaying);
}
}
setRingProgress(el, per) {
if (!el) return;
const clamped = Math.min(1, Math.max(0, per || 0));
el.style.strokeDashoffset = String(this.RING_C * (1 - clamped));
}
/** /**
* Play a song in the playlist. * Play a song in the playlist.
* @param {Number} index Index of the song in the playlist (leave empty to play the first or current). * @param {Number} index Index of the song in the playlist (leave empty to play the first or current).
@@ -217,21 +538,14 @@ const playerArtworkOpacity = 0.4;
// Start updating the progress of the track. // Start updating the progress of the track.
requestAnimationFrame(this.step.bind(this)); requestAnimationFrame(this.step.bind(this));
this.UI.controls.pause.classList.remove("hidden"); this.syncPlayPauseUI(true);
this.UI.controls.play.classList.add("hidden");
}, },
// onload: () => {
// loading.style.display = "none";
// },
onend: () => { onend: () => {
this.skip("next"); this.skip("next");
}, },
onpause: () => { onpause: () => {
this.UI.controls.pause.classList.add("hidden"); this.syncPlayPauseUI(false);
this.UI.controls.play.classList.remove("hidden");
}, },
// onstop: () => {
// },
onseek: () => { onseek: () => {
// Start updating the progress of the track. // Start updating the progress of the track.
requestAnimationFrame(this.step.bind(this)); requestAnimationFrame(this.step.bind(this));
@@ -250,18 +564,10 @@ const playerArtworkOpacity = 0.4;
Math.round(data.duration) Math.round(data.duration)
); );
this.UI.progress.buffered.style.width = "0%"; this.UI.progress.buffered.style.width = "0%";
this.setRingProgress(this.UI.faceProgress.buffered, 0);
this.setRingProgress(this.UI.faceProgress.played, 0);
if (data.metadata.artwork) { this.updateArtwork(data);
this.UI.artwork.background.style.backgroundImage = `url('${data.metadata.artwork.src}')`;
this.UI.artwork.background.style.backgroundSize = "cover";
this.UI.artwork.background.style.backgroundPosition = "center";
this.UI.artwork.background.style.filter = `blur(${playerArtworkBlur})`;
this.UI.artwork.background.style.opacity = playerArtworkOpacity;
} else {
this.UI.artwork.background.style.backgroundImage = "";
this.UI.artwork.background.style.filter = "";
this.UI.artwork.background.style.opacity = "0";
}
// Keep track of the index we are currently playing. // Keep track of the index we are currently playing.
this.index = index; this.index = index;
@@ -276,8 +582,7 @@ const playerArtworkOpacity = 0.4;
sound.pause(); sound.pause();
this.UI.controls.pause.classList.add("hidden"); this.syncPlayPauseUI(false);
this.UI.controls.play.classList.remove("hidden");
} }
/** /**
@@ -316,6 +621,7 @@ const playerArtworkOpacity = 0.4;
// Reset progress. // Reset progress.
this.UI.progress.played.style.width = "0%"; this.UI.progress.played.style.width = "0%";
this.setRingProgress(this.UI.faceProgress.played, 0);
this.UI.times.current.textContent = this.formatTime(0); this.UI.times.current.textContent = this.formatTime(0);
// Play the new track. // Play the new track.
@@ -345,10 +651,16 @@ const playerArtworkOpacity = 0.4;
var seek = sound.seek() || 0; var seek = sound.seek() || 0;
this.UI.times.current.textContent = this.formatTime(Math.round(seek)); this.UI.times.current.textContent = this.formatTime(Math.round(seek));
this.UI.progress.played.style.width = const playedPer = seek / sound.duration() || 0;
((seek / sound.duration()) * 100 || 0) + "%"; this.UI.progress.played.style.width = playedPer * 100 + "%";
this.setRingProgress(this.UI.faceProgress.played, playedPer);
this.UI.progress.buffered.style.width = `${this.getBufferedAmount(sound).bufferedPercent}%`; const bufferedPercent = this.getBufferedAmount(sound).bufferedPercent;
this.UI.progress.buffered.style.width = `${bufferedPercent}%`;
this.setRingProgress(
this.UI.faceProgress.buffered,
bufferedPercent / 100
);
// If the sound is still playing, continue stepping. // If the sound is still playing, continue stepping.
if (sound.playing()) { if (sound.playing()) {
@@ -378,13 +690,14 @@ const playerArtworkOpacity = 0.4;
getBufferedAmount(sound) { getBufferedAmount(sound) {
if (sound._sounds.length > 0) { if (sound._sounds.length > 0) {
const audioNode = sound._sounds[0]._node; // The underlying HTML5 Audio element const audioNode = sound._sounds[0]._node; // Underlying HTML5 Audio element
if (audioNode.buffered.length > 0) { if (audioNode.buffered.length > 0) {
const bufferedEnd = audioNode.buffered.end(0); // Time (seconds) where buffering ends const bufferedEnd = audioNode.buffered.end(0);
const duration = audioNode.duration; const duration = audioNode.duration;
const bufferedSeconds = bufferedEnd; return {
const bufferedPercent = duration ? (bufferedEnd / duration) * 100 : 0; bufferedSeconds: bufferedEnd,
return { bufferedSeconds, bufferedPercent }; bufferedPercent: duration ? (bufferedEnd / duration) * 100 : 0
};
} }
} }
return { bufferedSeconds: 0, bufferedPercent: 0 }; return { bufferedSeconds: 0, bufferedPercent: 0 };
@@ -411,5 +724,17 @@ const playerArtworkOpacity = 0.4;
if (!window.playerIsInitialised) { if (!window.playerIsInitialised) {
window.player = new Player(initialQueue); window.player = new Player(initialQueue);
} else {
// View-transition restore: re-grab nodes and re-apply.
try {
const p = window.player;
p.UI.root = $("player-shell");
p.UI.spacer = $("player:spacer");
p.UI.full = document.querySelector("#player-shell .player-full");
p.UI.face = document.querySelector("#player-shell .player-face");
p.applyminimised();
} catch (e) {
/* ignore */
}
} }
</script> </script>
+2 -3
View File
@@ -19,9 +19,8 @@ import { Image } from "astro:assets";
<Image <Image
src={aboutImage} src={aboutImage}
alt="A photo of Nathan conducting the Woodville Concert Band" alt="A photo of Nathan conducting the Woodville Concert Band"
class="mx-auto mb-8 size-96 rounded-full object-cover shadow-lg/75 transition ease-in-out md:hover:scale-150" class="mx-auto mb-8 size-125 rounded-full object-cover shadow-lg/75 transition ease-in-out md:hover:scale-150"
transition:name="aboutImage" height={750}
height={576}
/> />
<SectionTitle class="py-8">Dr Nathan Cummins</SectionTitle> <SectionTitle class="py-8">Dr Nathan Cummins</SectionTitle>
<H2>{site.tagline}</H2> <H2>{site.tagline}</H2>
-1
View File
@@ -93,7 +93,6 @@ const tracks = (
src={aboutImage} src={aboutImage}
alt="Nathan conducting the Woodville Concert Band" alt="Nathan conducting the Woodville Concert Band"
class="mx-auto mt-8 size-96 rounded-full object-cover shadow-lg/75 transition ease-in-out md:hover:scale-150" class="mx-auto mt-8 size-96 rounded-full object-cover shadow-lg/75 transition ease-in-out md:hover:scale-150"
transition:name="aboutImage"
height={576} height={576}
/> />
<span class="block w-full text-center" <span class="block w-full text-center"