Make player controls keyboard-operable, with slider seeking, pre-play seeks, and an expand button
This commit is contained in:
1 file changed
+132
-44
+132
-44
@@ -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"
|
||||
<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"
|
||||
class="hidden"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<Icon name="fa7-solid:forward" id="player:controls:next" />
|
||||
</span>
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user