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;
|
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;
|
||||||
|
|||||||
Reference in new issue
Block a user