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
+133 -45
+133 -45
View File
@@ -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;
</div>
<div
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"
>
<div
@@ -89,8 +95,7 @@ const playerArtworkOpacity = 0.4;
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" />
><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
@@ -100,20 +105,34 @@ const playerArtworkOpacity = 0.4;
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">
<span>
<Icon name="fa7-solid:backward" id="player:controls:previous" />
</span>
<span>
<Icon name="fa7-solid:play" id="player:controls:play" />
<Icon
name="fa7-solid:pause"
id="player:controls:pause"
class="hidden"
/>
</span>
<span>
<Icon name="fa7-solid:forward" id="player:controls:next" />
</span>
<button
type="button"
id="player:controls:previous"
aria-label="Previous track"
class="rounded-full bg-transparent p-0 hover:cursor-pointer"
><Icon name="fa7-solid:backward" class="pointer-events-none" />
</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"
aria-label="Pause"
class="hidden rounded-full bg-transparent p-0 hover:cursor-pointer"
><Icon name="fa7-solid:pause" class="pointer-events-none" />
</button>
<button
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 class="flex flex-grow flex-col text-sm">
<span
@@ -176,21 +195,26 @@ const playerArtworkOpacity = 0.4;
<button
id="player:face:controls:toggle"
type="button"
title="Play / pause"
aria-label="Play / pause"
title="Play"
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"
>
<Icon
><Icon
name="fa7-solid:play"
id="player:face:controls:play"
class="pointer-events-none text-sm drop-shadow"
/>
<Icon
/><Icon
name="fa7-solid:pause"
id="player:face:controls:pause"
class="pointer-events-none hidden text-sm drop-shadow"
/>
</button>
<button
id="player:face:controls:expand"
type="button"
aria-label="Expand player"
class="sr-only"
>Expand player
</button>
</div>
</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.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;