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;