Make player controls keyboard-operable, with slider seeking, pre-play seeks, and an expand button

This commit is contained in:
encie22 committed 2026-10-15 15:58:47 +10:30
1 parent f5d2b417e6
commit 4624fe722b
1 file changed
+132 -44
+132 -44
View File
@@ -35,7 +35,7 @@ const initialQueue = await Promise.all(
}) })
); );
const playerArtworkBlur = "10px"; const playerArtworkBlur = "15px";
const playerArtworkOpacity = 0.4; const playerArtworkOpacity = 0.4;
--- ---
@@ -62,6 +62,12 @@ const playerArtworkOpacity = 0.4;
</div> </div>
<div <div
id="player:time:progress" id="player:time:progress"
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
class="relative top-0 right-0 left-0 m-0 h-2 w-full rounded-t p-0 hover:cursor-pointer" class="relative top-0 right-0 left-0 m-0 h-2 w-full rounded-t p-0 hover:cursor-pointer"
> >
<div <div
@@ -89,8 +95,7 @@ const playerArtworkOpacity = 0.4;
title="Minimise player" title="Minimise player"
aria-label="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" 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" />
<Icon name="fa7-solid:chevron-down" class="pointer-events-none" />
</button> </button>
<span id="player:times:duration" class="col-span-1 text-right" <span id="player:times:duration" class="col-span-1 text-right"
>0:00</span >0:00</span
@@ -100,20 +105,34 @@ const playerArtworkOpacity = 0.4;
class="flex h-full flex-row items-center justify-center align-middle" 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> <button
<Icon name="fa7-solid:backward" id="player:controls:previous" /> type="button"
</span> id="player:controls:previous"
<span> aria-label="Previous track"
<Icon name="fa7-solid:play" id="player:controls:play" /> class="rounded-full bg-transparent p-0 hover:cursor-pointer"
<Icon ><Icon name="fa7-solid:backward" class="pointer-events-none" />
name="fa7-solid:pause" </button>
<button
type="button"
id="player:controls:play"
aria-label="Play"
class="rounded-full bg-transparent p-0 hover:cursor-pointer"
><Icon name="fa7-solid:play" class="pointer-events-none" />
</button>
<button
type="button"
id="player:controls:pause" id="player:controls:pause"
class="hidden" aria-label="Pause"
/> class="hidden rounded-full bg-transparent p-0 hover:cursor-pointer"
</span> ><Icon name="fa7-solid:pause" class="pointer-events-none" />
<span> </button>
<Icon name="fa7-solid:forward" id="player:controls:next" /> <button
</span> type="button"
id="player:controls:next"
aria-label="Next track"
class="rounded-full bg-transparent p-0 hover:cursor-pointer"
><Icon name="fa7-solid:forward" class="pointer-events-none" />
</button>
</div> </div>
<div class="flex flex-grow flex-col text-sm"> <div class="flex flex-grow flex-col text-sm">
<span <span
@@ -176,21 +195,26 @@ const playerArtworkOpacity = 0.4;
<button <button
id="player:face:controls:toggle" id="player:face:controls:toggle"
type="button" type="button"
title="Play / pause" title="Play"
aria-label="Play / pause" aria-label="Play"
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" 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
<Icon
name="fa7-solid:play" name="fa7-solid:play"
id="player:face:controls:play" id="player:face:controls:play"
class="pointer-events-none text-sm drop-shadow" class="pointer-events-none text-sm drop-shadow"
/> /><Icon
<Icon
name="fa7-solid:pause" name="fa7-solid:pause"
id="player:face:controls:pause" id="player:face:controls:pause"
class="pointer-events-none hidden text-sm drop-shadow" class="pointer-events-none hidden text-sm drop-shadow"
/> />
</button> </button>
<button
id="player:face:controls:expand"
type="button"
aria-label="Expand player"
class="sr-only"
>Expand player
</button>
</div> </div>
</div> </div>
<div id="player:spacer" class:list={[height, "bg-gray-900"]}></div> <div id="player:spacer" class:list={[height, "bg-gray-900"]}></div>
@@ -301,6 +325,8 @@ const playerArtworkOpacity = 0.4;
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.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 this.RING_C = 182.2; // 2 * PI * 29, must match dasharray in markup
@@ -344,7 +370,8 @@ const playerArtworkOpacity = 0.4;
faceControls: { faceControls: {
toggle: $("player:face:controls:toggle"), toggle: $("player:face:controls:toggle"),
play: $("player:face:controls:play"), 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)); 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). // Tap the circle to expand (not the play button).
const expand = (e) => { const expand = (e) => {
if (!window.player.minimised) return; if (!window.player.minimised) return;
@@ -414,12 +469,14 @@ const playerArtworkOpacity = 0.4;
) { ) {
return; return;
} }
if (e.type === "keydown" && e.key !== "Enter" && e.key !== " ") return;
e.preventDefault(); e.preventDefault();
window.player.setminimised(false); window.player.setminimised(false);
}; };
this.UI.root.addEventListener("click", expand); 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) => { this.UI.faceControls.toggle.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const sound = window.player.playlist[window.player.index].howl; 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. // 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() { applyminimised() {
if (!this.UI.root) return; if (!this.UI.root) return;
if (this.minimised) { if (this.minimised) {
this.UI.root.classList.add("player--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.spacer?.classList.add("player-spacer--hidden");
this.UI.full?.setAttribute("aria-hidden", "true"); this.UI.full?.setAttribute("aria-hidden", "true");
this.UI.face?.setAttribute("aria-hidden", "false"); this.UI.face?.setAttribute("aria-hidden", "false");
} else { } else {
this.UI.root.classList.remove("player--minimised"); 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.spacer?.classList.remove("player-spacer--hidden");
this.UI.full?.removeAttribute("aria-hidden"); this.UI.full?.removeAttribute("aria-hidden");
this.UI.face?.setAttribute("aria-hidden", "true"); this.UI.face?.setAttribute("aria-hidden", "true");
@@ -506,6 +551,14 @@ const playerArtworkOpacity = 0.4;
set.pause.classList.toggle("hidden", !isPlaying); set.pause.classList.toggle("hidden", !isPlaying);
set.play.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) { setRingProgress(el, per) {
@@ -559,6 +612,17 @@ const playerArtworkOpacity = 0.4;
// Begin playing the sound. // Begin playing the sound.
sound.play(); 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. // Update the track display.
this.UI.metadata.title.textContent = data.metadata.title; this.UI.metadata.title.textContent = data.metadata.title;
this.UI.metadata.subtitle.textContent = data.metadata.subtitle; this.UI.metadata.subtitle.textContent = data.metadata.subtitle;
@@ -618,6 +682,9 @@ const playerArtworkOpacity = 0.4;
* @param {Number} index Index in the playlist. * @param {Number} index Index in the playlist.
*/ */
skipTo(index) { skipTo(index) {
// A pre-play seek belongs to the old track: drop it.
this.pendingSeek = null;
// Stop the current track. // Stop the current track.
if (this.playlist[this.index].howl) { if (this.playlist[this.index].howl) {
this.playlist[this.index].howl.stop(); this.playlist[this.index].howl.stop();
@@ -637,13 +704,30 @@ const playerArtworkOpacity = 0.4;
* @param {Number} per Percentage through the song to skip. * @param {Number} per Percentage through the song to skip.
*/ */
seek(per) { seek(per) {
// Get the Howl we want to manipulate. const entry = this.playlist[this.index];
var sound = this.playlist[this.index].howl; if (!entry) return;
const clamped = Math.min(Math.max(per, 0), 1);
const total = entry.duration || 0;
const target = total * clamped;
if (sound.playing()) { var sound = entry.howl;
// Convert the percent into a seek position. if (!sound) {
sound.seek(sound.duration() * per); // 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)); this.UI.times.current.textContent = this.formatTime(Math.round(seek));
const playedPer = seek / sound.duration() || 0; const playedPer = seek / sound.duration() || 0;
this.UI.progress.played.style.width = playedPer * 100 + "%"; 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); this.setRingProgress(this.UI.faceProgress.played, playedPer);
const bufferedPercent = this.getBufferedAmount(sound).bufferedPercent; const bufferedPercent = this.getBufferedAmount(sound).bufferedPercent;