diff --git a/src/components/Player.astro b/src/components/Player.astro
index 904cfd2..2ffc583 100644
--- a/src/components/Player.astro
+++ b/src/components/Player.astro
@@ -35,7 +35,7 @@ const initialQueue = await Promise.all(
})
);
-const playerArtworkBlur = "10px";
+const playerArtworkBlur = "15px";
const playerArtworkOpacity = 0.4;
---
@@ -62,6 +62,12 @@ const playerArtworkOpacity = 0.4;
-
+ >
0:00
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
+
@@ -301,6 +325,8 @@ const playerArtworkOpacity = 0.4;
this.playlist = window.player?.playlist ?? playlist;
this.index = window.player?.index ?? 0;
this.minimised = window.player?.minimised ?? false;
+ // Seek fraction requested before anything was loaded.
+ this.pendingSeek = null;
this.RING_C = 182.2; // 2 * PI * 29, must match dasharray in markup
@@ -344,7 +370,8 @@ const playerArtworkOpacity = 0.4;
faceControls: {
toggle: $("player:face:controls:toggle"),
play: $("player:face:controls:play"),
- pause: $("player:face:controls:pause")
+ pause: $("player:face:controls:pause"),
+ expand: $("player:face:controls:expand")
}
};
@@ -405,6 +432,34 @@ const playerArtworkOpacity = 0.4;
window.player.seek(Math.min(Math.max(per, 0), 1));
});
+ this.UI.progress.bar.addEventListener("keydown", (event) => {
+ if (
+ event.key !== "ArrowLeft" &&
+ event.key !== "ArrowRight" &&
+ event.key !== "Home" &&
+ event.key !== "End"
+ ) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ const player = window.player;
+ const current = player.playlist[player.index];
+ if (!current) return;
+ const sound = current.howl;
+ const duration = sound?.duration() || 0 || current.duration || 0;
+ if (duration <= 0) return;
+ const position = sound
+ ? sound.seek() || 0
+ : (player.pendingSeek ?? 0) * duration;
+ let target = position;
+ if (event.key === "ArrowLeft") target = position - 5;
+ if (event.key === "ArrowRight") target = position + 5;
+ if (event.key === "Home") target = 0;
+ if (event.key === "End") target = duration;
+ window.player.seek(duration > 0 ? target / duration : 0);
+ });
+
// Tap the circle to expand (not the play button).
const expand = (e) => {
if (!window.player.minimised) return;
@@ -414,12 +469,14 @@ const playerArtworkOpacity = 0.4;
) {
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.expand.addEventListener("click", (e) => {
+ e.stopPropagation();
+ window.player.setminimised(false);
+ });
this.UI.faceControls.toggle.addEventListener("click", (e) => {
e.stopPropagation();
const sound = window.player.playlist[window.player.index].howl;
@@ -444,29 +501,17 @@ const playerArtworkOpacity = 0.4;
}
// One class drives the morph; re-applying is a no-op.
+ // Note: the shell stays a plain div so the face buttons inside it are
+ // never nested interactives; keyboard users get dedicated buttons.
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");
@@ -506,6 +551,14 @@ const playerArtworkOpacity = 0.4;
set.pause.classList.toggle("hidden", !isPlaying);
set.play.classList.toggle("hidden", isPlaying);
}
+ this.UI.faceControls.toggle?.setAttribute(
+ "aria-label",
+ isPlaying ? "Pause" : "Play"
+ );
+ this.UI.faceControls.toggle?.setAttribute(
+ "title",
+ isPlaying ? "Pause" : "Play"
+ );
}
setRingProgress(el, per) {
@@ -559,6 +612,17 @@ const playerArtworkOpacity = 0.4;
// Begin playing the sound.
sound.play();
+ // Apply a seek requested before anything was loaded.
+ if (this.pendingSeek != null) {
+ const target = data.duration * this.pendingSeek;
+ this.pendingSeek = null;
+ if (sound.state() === "loaded") {
+ sound.seek(target);
+ } else {
+ sound.once("load", () => sound.seek(target));
+ }
+ }
+
// Update the track display.
this.UI.metadata.title.textContent = data.metadata.title;
this.UI.metadata.subtitle.textContent = data.metadata.subtitle;
@@ -618,6 +682,9 @@ const playerArtworkOpacity = 0.4;
* @param {Number} index Index in the playlist.
*/
skipTo(index) {
+ // A pre-play seek belongs to the old track: drop it.
+ this.pendingSeek = null;
+
// Stop the current track.
if (this.playlist[this.index].howl) {
this.playlist[this.index].howl.stop();
@@ -637,13 +704,30 @@ const playerArtworkOpacity = 0.4;
* @param {Number} per Percentage through the song to skip.
*/
seek(per) {
- // Get the Howl we want to manipulate.
- var sound = this.playlist[this.index].howl;
+ const entry = this.playlist[this.index];
+ if (!entry) return;
+ const clamped = Math.min(Math.max(per, 0), 1);
+ const total = entry.duration || 0;
+ const target = total * clamped;
- if (sound.playing()) {
- // Convert the percent into a seek position.
- sound.seek(sound.duration() * per);
+ var sound = entry.howl;
+ if (!sound) {
+ // Nothing loaded yet: remember the request for play().
+ this.pendingSeek = clamped;
+ } else {
+ this.pendingSeek = null;
+ sound.seek((sound.duration() || total) * clamped);
}
+
+ // Refresh the position display even when paused or unloaded.
+ this.UI.times.current.textContent = this.formatTime(Math.round(target));
+ const playedPer = total > 0 ? clamped : 0;
+ this.UI.progress.played.style.width = playedPer * 100 + "%";
+ this.UI.progress.bar.setAttribute(
+ "aria-valuenow",
+ String(Math.round(playedPer * 100))
+ );
+ this.setRingProgress(this.UI.faceProgress.played, playedPer);
}
/**
@@ -657,6 +741,10 @@ const playerArtworkOpacity = 0.4;
this.UI.times.current.textContent = this.formatTime(Math.round(seek));
const playedPer = seek / sound.duration() || 0;
this.UI.progress.played.style.width = playedPer * 100 + "%";
+ this.UI.progress.bar.setAttribute(
+ "aria-valuenow",
+ String(Math.round(playedPer * 100))
+ );
this.setRingProgress(this.UI.faceProgress.played, playedPer);
const bufferedPercent = this.getBufferedAmount(sound).bufferedPercent;