Make shuffle reorder and restore the play queue, add off/all/one repeat playback, and remove Resonant Radio UI.

Hop-State: A_06FNNZKG8MKNEJA6QXQ1ZER
Hop-Proposal: R_06FNNZJMKS1Z9RBS9ADD3ER
Hop-Task: T_06FNNXTJEGA5TEAZ2GA55X0
Hop-Attempt: AT_06FNNXTJEHPH9SQPGKC0P9G
This commit is contained in:
2026-07-13 03:20:52 -07:00
committed by Hop
parent f34266b704
commit a4b66954bd
6 changed files with 274 additions and 44 deletions
+11 -7
View File
@@ -17,7 +17,8 @@ import {
Play,
Power,
Plus,
Radio,
Repeat1,
Repeat2,
Search,
Server,
Settings,
@@ -54,8 +55,8 @@ function Cover({ album, size = "medium" }: { album: Pick<Album, "title" | "artis
);
}
function IconButton({ label, children, onClick, active = false, className = "" }: { label: string; children: React.ReactNode; onClick?: () => void; active?: boolean; className?: string }) {
return <button type="button" className={`icon-button ${active ? "is-active" : ""} ${className}`} aria-label={label} aria-pressed={active || undefined} onClick={onClick}>{children}</button>;
function IconButton({ label, children, onClick, active = false, pressed, className = "" }: { label: string; children: React.ReactNode; onClick?: () => void; active?: boolean; pressed?: boolean; className?: string }) {
return <button type="button" className={`icon-button ${active ? "is-active" : ""} ${className}`} aria-label={label} aria-pressed={pressed ?? (active || undefined)} onClick={onClick}>{children}</button>;
}
const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [
@@ -95,6 +96,11 @@ export default function App() {
});
const player = usePlayer(libraryTracks);
const hasLibraryTracks = libraryTracks.length > 0;
const repeatLabel = player.repeatMode === "off"
? "Turn repeat all on"
: player.repeatMode === "all"
? "Repeat all is on; turn repeat one on"
: "Repeat one is on; turn repeat off";
const visibleTracks = useMemo(() => {
const needle = query.trim().toLowerCase();
@@ -326,8 +332,6 @@ export default function App() {
{item.icon}<span>{item.label}</span>
</button>
))}
<p className="nav-label nav-label--second">Discover</p>
<button type="button" onClick={() => goTo("home")}><Radio size={18} /><span>Resonant Radio</span><em>Soon</em></button>
</nav>
<button className="server-pill" type="button" onClick={() => goTo("settings")}>
<span className={`status-dot status-dot--${connectionStatus}`} /><span><strong>{managedStatus?.running ? "This computer" : connectionStatus === "connected" ? "Your server" : "Demo library"}</strong><small>{connectionMessage}</small></span><Settings size={16} />
@@ -390,7 +394,7 @@ export default function App() {
) : <>
{hasLibraryTracks && view === "home" && !query && (
<section className="hero" aria-labelledby="hero-title">
<div className="hero-copy"><span className="eyebrow">Picked from your library</span><h1 id="hero-title">Let it find you again.</h1><p>{player.current.artist} has been in your rotation lately. Start here, then let the queue wander.</p><div className="hero-actions"><button className="button-primary button-primary--light" type="button" onClick={player.toggle}>{player.isPlaying ? <Pause size={17} fill="currentColor" /> : <Play size={17} fill="currentColor" />} {player.isPlaying ? "Pause" : "Play now"}</button><button className="button-quiet" type="button" onClick={() => player.addToQueue(libraryTracks[Math.min(4, libraryTracks.length - 1)])}><Shuffle size={17} /> Mix from here</button></div></div>
<div className="hero-copy"><span className="eyebrow">Picked from your library</span><h1 id="hero-title">Let it find you again.</h1><p>{player.current.artist} has been in your rotation lately. Start here, then let the queue wander.</p><div className="hero-actions"><button className="button-primary button-primary--light" type="button" onClick={player.toggle}>{player.isPlaying ? <Pause size={17} fill="currentColor" /> : <Play size={17} fill="currentColor" />} {player.isPlaying ? "Pause" : "Play now"}</button><button className={`button-quiet ${player.isShuffled ? "is-active" : ""}`} type="button" aria-pressed={player.isShuffled} onClick={player.toggleShuffle}><Shuffle size={17} /> {player.isShuffled ? "Shuffle on" : "Shuffle queue"}</button></div></div>
<div className="hero-art" aria-hidden="true"><Cover album={player.current} size="large" /><div className="orbit orbit-one" /><div className="orbit orbit-two" /></div>
</section>
)}
@@ -413,7 +417,7 @@ export default function App() {
{hasLibraryTracks ? <footer className="player-bar">
<button className="now-playing" type="button" onClick={() => setQueueOpen(true)}><Cover album={player.current} size="small" /><span><strong>{player.current.title}</strong><small>{player.current.artist}</small></span><Heart size={16} className={favorites.has(player.current.id) ? "filled-heart" : ""} fill={favorites.has(player.current.id) ? "currentColor" : "none"} /></button>
<div className="transport"><div className="transport-buttons"><IconButton label="Shuffle"><Shuffle size={16} /></IconButton><IconButton label="Previous track" onClick={player.previous}><SkipBack size={19} fill="currentColor" /></IconButton><button className="play-button" type="button" aria-label={player.isPlaying ? "Pause" : "Play"} onClick={player.toggle}>{player.isPlaying ? <Pause size={20} fill="currentColor" /> : <Play size={20} fill="currentColor" />}</button><IconButton label="Next track" onClick={player.next}><SkipForward size={19} fill="currentColor" /></IconButton><IconButton label="Repeat"><Radio size={16} /></IconButton></div><div className="scrubber"><span>{formatTime(player.progress)}</span><input type="range" aria-label="Playback position" min="0" max={Math.max(player.duration, 1)} step="1" value={Math.min(player.progress, player.duration)} onChange={(event) => player.seek(Number(event.target.value))} style={{ "--range-progress": `${(player.progress / Math.max(player.duration, 1)) * 100}%` } as React.CSSProperties} /><span>{formatTime(player.duration)}</span></div></div>
<div className="transport"><div className="transport-buttons"><IconButton label={player.isShuffled ? "Turn shuffle off" : "Turn shuffle on"} active={player.isShuffled} pressed={player.isShuffled} onClick={player.toggleShuffle}><Shuffle size={16} /></IconButton><IconButton label="Previous track" onClick={player.previous}><SkipBack size={19} fill="currentColor" /></IconButton><button className="play-button" type="button" aria-label={player.isPlaying ? "Pause" : "Play"} onClick={player.toggle}>{player.isPlaying ? <Pause size={20} fill="currentColor" /> : <Play size={20} fill="currentColor" />}</button><IconButton label="Next track" onClick={player.next}><SkipForward size={19} fill="currentColor" /></IconButton><IconButton label={repeatLabel} active={player.repeatMode !== "off"} pressed={player.repeatMode !== "off"} onClick={player.cycleRepeat}>{player.repeatMode === "one" ? <Repeat1 size={16} /> : <Repeat2 size={16} />}</IconButton></div><div className="scrubber"><span>{formatTime(player.progress)}</span><input type="range" aria-label="Playback position" min="0" max={Math.max(player.duration, 1)} step="1" value={Math.min(player.progress, player.duration)} onChange={(event) => player.seek(Number(event.target.value))} style={{ "--range-progress": `${(player.progress / Math.max(player.duration, 1)) * 100}%` } as React.CSSProperties} /><span>{formatTime(player.duration)}</span></div></div>
<div className="player-tools"><IconButton label="Open queue" active={queueOpen} onClick={() => setQueueOpen(!queueOpen)}><ListMusic size={19} /></IconButton><Volume2 size={18} /><input type="range" aria-label="Volume" min="0" max="1" step="0.01" value={player.volume} onChange={(event) => player.changeVolume(Number(event.target.value))} style={{ "--range-progress": `${player.volume * 100}%` } as React.CSSProperties} /></div>
</footer> : <footer className="player-bar player-bar--library-status"><div className="player-library-status"><span className={`status-dot status-dot--${connectionStatus}`} /><div><strong>{libraryPhase === "scanning" ? "Scanning your music" : "No tracks available"}</strong><small>{connectionMessage}</small></div></div><button type="button" className="button-secondary" onClick={() => goTo("settings")}>Library source</button></footer>}
</div>
+63
View File
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { Track } from "../types";
import { usePlayer } from "./usePlayer";
class AudioMock extends EventTarget {
currentTime = 0;
duration = 180;
paused = true;
preload = "";
src = "";
volume = 1;
load() {}
pause() {
this.paused = true;
}
play() {
this.paused = false;
return Promise.resolve();
}
}
const tracks = ["First", "Second", "Third", "Fourth"].map((title, index): Track => ({
id: String(index + 1),
title,
artist: "Artist",
album: "Album",
albumId: "album",
duration: 180,
coverTone: "indigo",
}));
describe("usePlayer playback modes", () => {
beforeAll(() => vi.stubGlobal("Audio", AudioMock));
afterAll(() => vi.unstubAllGlobals());
it("toggles queue shuffle and cycles all repeat modes", () => {
const { result } = renderHook(() => usePlayer(tracks));
const originalQueue = result.current.queue.map((track) => track.id);
act(() => result.current.toggleShuffle());
expect(result.current.isShuffled).toBe(true);
expect(result.current.queue.map((track) => track.id)).not.toEqual(originalQueue);
expect(new Set(result.current.queue.map((track) => track.id))).toEqual(new Set(originalQueue));
act(() => result.current.toggleShuffle());
expect(result.current.isShuffled).toBe(false);
expect(result.current.queue.map((track) => track.id)).toEqual(originalQueue);
act(() => result.current.cycleRepeat());
expect(result.current.repeatMode).toBe("all");
act(() => result.current.cycleRepeat());
expect(result.current.repeatMode).toBe("one");
act(() => result.current.cycleRepeat());
expect(result.current.repeatMode).toBe("off");
});
});
+156 -35
View File
@@ -1,14 +1,82 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { nextRepeatMode, shuffleTracks, type RepeatMode } from "../lib/playback";
import type { Track } from "../types";
export function usePlayer(initialTracks: Track[]) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const tracksRef = useRef(initialTracks);
const currentRef = useRef<Track>(initialTracks[0]);
const queueRef = useRef<Track[]>(initialTracks.slice(1, 5));
const historyRef = useRef<Track[]>([]);
const shuffleRef = useRef(false);
const repeatRef = useRef<RepeatMode>("off");
const [current, setCurrent] = useState<Track>(initialTracks[0]);
const [queue, setQueue] = useState<Track[]>(initialTracks.slice(1, 5));
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(initialTracks[0]?.duration ?? 0);
const [volume, setVolume] = useState(0.72);
const [isShuffled, setIsShuffled] = useState(false);
const [repeatMode, setRepeatMode] = useState<RepeatMode>("off");
const commitCurrent = useCallback((track: Track) => {
currentRef.current = track;
setCurrent(track);
}, []);
const commitQueue = useCallback((items: Track[]) => {
queueRef.current = items;
setQueue(items);
}, []);
const restartCurrent = useCallback((resume: boolean) => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = 0;
setProgress(0);
if (resume) {
void audio.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
}
}, []);
const advance = useCallback((fromEnded = false) => {
if (fromEnded && repeatRef.current === "one") {
restartCurrent(true);
return;
}
const currentTrack = currentRef.current;
const nextTrack = queueRef.current[0];
if (nextTrack) {
historyRef.current = [...historyRef.current, currentTrack];
commitQueue(queueRef.current.slice(1));
commitCurrent(nextTrack);
return;
}
if (repeatRef.current === "all") {
let cycle = [...historyRef.current, currentTrack];
if (shuffleRef.current) {
cycle = shuffleTracks(cycle);
if (cycle.length > 1 && cycle[0].id === currentTrack.id) {
cycle.push(cycle.shift()!);
}
}
historyRef.current = [];
if (cycle.length === 1) {
restartCurrent(fromEnded);
return;
}
commitCurrent(cycle[0]);
commitQueue(cycle.slice(1));
return;
}
if (fromEnded) setIsPlaying(false);
}, [commitCurrent, commitQueue, restartCurrent]);
useEffect(() => {
const audio = new Audio();
@@ -19,14 +87,7 @@ export function usePlayer(initialTracks: Track[]) {
setProgress(audio.currentTime);
if (Number.isFinite(audio.duration)) setDuration(audio.duration);
};
const ended = () => {
setQueue((items) => {
const next = items[0];
if (next) setCurrent(next);
else setIsPlaying(false);
return next ? items.slice(1) : items;
});
};
const ended = () => advance(true);
audio.addEventListener("timeupdate", update);
audio.addEventListener("loadedmetadata", update);
audio.addEventListener("ended", ended);
@@ -36,7 +97,7 @@ export function usePlayer(initialTracks: Track[]) {
audio.removeEventListener("loadedmetadata", update);
audio.removeEventListener("ended", ended);
};
}, []);
}, [advance]);
useEffect(() => {
const audio = audioRef.current;
@@ -49,9 +110,16 @@ export function usePlayer(initialTracks: Track[]) {
}, [current]);
const play = useCallback((track: Track) => {
setCurrent(track);
if (track.id === currentRef.current.id) {
void audioRef.current?.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
return;
}
historyRef.current = [...historyRef.current, currentRef.current];
commitQueue(queueRef.current.filter((queuedTrack) => queuedTrack.id !== track.id));
commitCurrent(track);
setIsPlaying(true);
}, []);
}, [commitCurrent, commitQueue]);
const toggle = useCallback(() => {
const audio = audioRef.current;
@@ -75,12 +143,7 @@ export function usePlayer(initialTracks: Track[]) {
if (audioRef.current) audioRef.current.volume = next;
}, []);
const next = useCallback(() => {
const nextTrack = queue[0];
if (!nextTrack) return;
setCurrent(nextTrack);
setQueue((items) => items.slice(1));
}, [queue]);
const next = useCallback(() => advance(false), [advance]);
const previous = useCallback(() => {
const audio = audioRef.current;
@@ -88,9 +151,74 @@ export function usePlayer(initialTracks: Track[]) {
audio.currentTime = 0;
return;
}
const index = initialTracks.findIndex((track) => track.id === current.id);
if (index > 0) setCurrent(initialTracks[index - 1]);
}, [current.id, initialTracks]);
const previousTrack = historyRef.current.at(-1);
if (!previousTrack) return;
historyRef.current = historyRef.current.slice(0, -1);
commitQueue([currentRef.current, ...queueRef.current]);
commitCurrent(previousTrack);
}, [commitCurrent, commitQueue]);
const toggleShuffle = useCallback(() => {
const nextShuffled = !shuffleRef.current;
shuffleRef.current = nextShuffled;
setIsShuffled(nextShuffled);
let upcoming = queueRef.current;
if (nextShuffled) {
if (upcoming.length === 0) {
const playedIds = new Set([...historyRef.current, currentRef.current].map((track) => track.id));
upcoming = tracksRef.current.filter((track) => !playedIds.has(track.id));
}
const shuffled = shuffleTracks(upcoming);
if (shuffled.length > 1 && shuffled.every((track, index) => track.id === upcoming[index].id)) {
shuffled.push(shuffled.shift()!);
}
commitQueue(shuffled);
return;
}
const libraryOrder = new Map(tracksRef.current.map((track, index) => [track.id, index]));
commitQueue(upcoming
.map((track, index) => ({ track, index }))
.sort((a, b) => (libraryOrder.get(a.track.id) ?? Number.MAX_SAFE_INTEGER) - (libraryOrder.get(b.track.id) ?? Number.MAX_SAFE_INTEGER) || a.index - b.index)
.map(({ track }) => track));
}, [commitQueue]);
const cycleRepeat = useCallback(() => {
const nextMode = nextRepeatMode(repeatRef.current);
repeatRef.current = nextMode;
setRepeatMode(nextMode);
}, []);
const addToQueue = useCallback((track: Track) => {
commitQueue([...queueRef.current, track]);
}, [commitQueue]);
const removeFromQueue = useCallback((id: string) => {
commitQueue(queueRef.current.filter((track) => track.id !== id));
}, [commitQueue]);
const clearTracks = useCallback(() => {
audioRef.current?.pause();
tracksRef.current = [];
historyRef.current = [];
commitQueue([]);
setIsPlaying(false);
setProgress(0);
setDuration(0);
}, [commitQueue]);
const replaceTracks = useCallback((items: Track[]) => {
if (items.length === 0) return;
audioRef.current?.pause();
tracksRef.current = items;
historyRef.current = [];
commitCurrent(items[0]);
commitQueue(shuffleRef.current ? shuffleTracks(items.slice(1, 8)) : items.slice(1, 8));
setIsPlaying(false);
setProgress(0);
setDuration(items[0].duration);
}, [commitCurrent, commitQueue]);
return {
current,
@@ -99,26 +227,19 @@ export function usePlayer(initialTracks: Track[]) {
progress,
duration,
volume,
isShuffled,
repeatMode,
play,
toggle,
seek,
changeVolume,
next,
previous,
addToQueue: (track: Track) => setQueue((items) => [...items, track]),
removeFromQueue: (id: string) => setQueue((items) => items.filter((track) => track.id !== id)),
clearTracks: () => {
audioRef.current?.pause();
setQueue([]);
setIsPlaying(false);
setProgress(0);
setDuration(0);
},
replaceTracks: (items: Track[]) => {
if (items.length === 0) return;
setCurrent(items[0]);
setQueue(items.slice(1, 8));
setIsPlaying(false);
},
toggleShuffle,
cycleRepeat,
addToQueue,
removeFromQueue,
clearTracks,
replaceTracks,
};
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { nextRepeatMode, shuffleTracks } from "./playback";
describe("shuffleTracks", () => {
it("shuffles a copy without changing the source queue", () => {
const source = ["first", "second", "third", "fourth"];
const randomValues = [0, 0.5, 0.25];
let call = 0;
const shuffled = shuffleTracks(source, () => randomValues[call++]);
expect(shuffled).toEqual(["third", "fourth", "second", "first"]);
expect(source).toEqual(["first", "second", "third", "fourth"]);
expect(shuffled).toHaveLength(source.length);
expect(new Set(shuffled)).toEqual(new Set(source));
});
});
describe("nextRepeatMode", () => {
it("cycles through off, all, one, and back to off", () => {
expect(nextRepeatMode("off")).toBe("all");
expect(nextRepeatMode("all")).toBe("one");
expect(nextRepeatMode("one")).toBe("off");
});
});
+18
View File
@@ -0,0 +1,18 @@
export type RepeatMode = "off" | "all" | "one";
export function nextRepeatMode(mode: RepeatMode): RepeatMode {
if (mode === "off") return "all";
if (mode === "all") return "one";
return "off";
}
export function shuffleTracks<T>(items: readonly T[], random: () => number = Math.random): T[] {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1));
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
}
return shuffled;
}
+1 -2
View File
@@ -42,12 +42,10 @@ button:disabled { cursor: wait; opacity: 0.65; }
.brand-mark { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 9px; color: oklch(0.16 0.015 275); background: var(--accent-bright); box-shadow: 0 8px 30px oklch(0.55 0.16 28 / 0.16); }
.sidebar nav { display: grid; gap: 4px; }
.nav-label { margin: 0 10px 8px; color: var(--faint); font-size: 0.67rem; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; }
.nav-label--second { margin-top: 25px; }
.sidebar nav button { display: flex; width: 100%; align-items: center; gap: 11px; padding: 9px 10px; border: 0; border-radius: 9px; color: var(--muted); background: transparent; font-size: 0.86rem; font-weight: 500; text-align: left; transition: color 160ms ease, background 160ms ease; }
.sidebar nav button:hover { color: var(--ink); background: oklch(0.23 0.012 275 / 0.62); }
.sidebar nav button.active { color: var(--ink); background: oklch(0.25 0.018 275 / 0.82); }
.sidebar nav button.active svg { color: var(--accent-bright); }
.sidebar nav em { margin-left: auto; padding: 2px 6px; border: 1px solid var(--line); border-radius: 99px; color: var(--faint); font-size: 0.58rem; font-style: normal; letter-spacing: 0.05em; text-transform: uppercase; }
.server-pill { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; margin-top: auto; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: oklch(0.19 0.01 275); text-align: left; }
.server-pill:hover { border-color: oklch(0.43 0.02 275); background: oklch(0.22 0.012 275); }
@@ -92,6 +90,7 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut
.button-secondary:hover { border-color: oklch(0.48 0.022 275); background: var(--surface-raised); }
.button-quiet { border: 1px solid oklch(0.79 0.018 275 / 0.22); color: oklch(0.88 0.01 275); background: oklch(0.15 0.012 275 / 0.3); }
.button-quiet:hover { background: oklch(0.74 0.02 275 / 0.13); }
.button-quiet.is-active { border-color: oklch(0.75 0.15 28 / 0.5); color: var(--accent-bright); background: oklch(0.68 0.17 28 / 0.12); }
.hero-art { position: relative; display: grid; min-height: 236px; place-items: center; }
.hero-art .cover { position: relative; z-index: 2; transform: rotate(3deg); box-shadow: -20px 28px 70px oklch(0.06 0.018 275 / 0.52); }
.orbit { position: absolute; width: 290px; height: 290px; border: 1px solid oklch(0.82 0.04 268 / 0.13); border-radius: 50%; }