Let playback traverse the full loaded library and show accessible checkmark feedback when tracks are added to the queue.
Hop-State: A_06FNP2RD4Z5F8MM3JJDGC90 Hop-Proposal: R_06FNP2QTMQDCQ4AD45XGSN8 Hop-Task: T_06FNP1W0F7R3YK8AM2XH0EG Hop-Attempt: AT_06FNP1W0F4Z53V2FHTSWX7R
This commit is contained in:
+32
-3
@@ -1,7 +1,8 @@
|
|||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Album as AlbumIcon,
|
Album as AlbumIcon,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleUserRound,
|
CircleUserRound,
|
||||||
Disc3,
|
Disc3,
|
||||||
@@ -46,6 +47,7 @@ import { buildApiUrl, requestSubsonic, streamUrl } from "./lib/subsonic";
|
|||||||
import type { Album, ConnectionStatus, CoverTone, ServerConfig, Track, View } from "./types";
|
import type { Album, ConnectionStatus, CoverTone, ServerConfig, Track, View } from "./types";
|
||||||
|
|
||||||
const tones: CoverTone[] = ["ember", "moss", "dusk", "clay", "indigo", "sand"];
|
const tones: CoverTone[] = ["ember", "moss", "dusk", "clay", "indigo", "sand"];
|
||||||
|
const queuePreviewLimit = 100;
|
||||||
|
|
||||||
function Cover({ album, size = "medium" }: { album: Pick<Album, "title" | "artist" | "coverTone" | "coverUrl">; size?: "small" | "medium" | "large" }) {
|
function Cover({ album, size = "medium" }: { album: Pick<Album, "title" | "artist" | "coverTone" | "coverUrl">; size?: "small" | "medium" | "large" }) {
|
||||||
return (
|
return (
|
||||||
@@ -76,6 +78,9 @@ export default function App() {
|
|||||||
const [view, setView] = useState<View>("home");
|
const [view, setView] = useState<View>("home");
|
||||||
const [mobileNav, setMobileNav] = useState(false);
|
const [mobileNav, setMobileNav] = useState(false);
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
const [queuedTrackIds, setQueuedTrackIds] = useState(() => new Set<string>());
|
||||||
|
const [queueAnnouncement, setQueueAnnouncement] = useState({ key: 0, message: "" });
|
||||||
|
const queueFeedbackTimers = useRef(new Map<string, number>());
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [libraryAlbums, setLibraryAlbums] = useState<Album[]>(demoAlbums);
|
const [libraryAlbums, setLibraryAlbums] = useState<Album[]>(demoAlbums);
|
||||||
const [libraryTracks, setLibraryTracks] = useState<Track[]>(demoTracks);
|
const [libraryTracks, setLibraryTracks] = useState<Track[]>(demoTracks);
|
||||||
@@ -120,6 +125,29 @@ export default function App() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const addTrackToQueue = (track: Track) => {
|
||||||
|
player.addToQueue(track);
|
||||||
|
setQueuedTrackIds((current) => new Set(current).add(track.id));
|
||||||
|
setQueueAnnouncement((current) => ({ key: current.key + 1, message: `${track.title} added to queue` }));
|
||||||
|
|
||||||
|
const existingTimer = queueFeedbackTimers.current.get(track.id);
|
||||||
|
if (existingTimer !== undefined) window.clearTimeout(existingTimer);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setQueuedTrackIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(track.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
queueFeedbackTimers.current.delete(track.id);
|
||||||
|
}, 1500);
|
||||||
|
queueFeedbackTimers.current.set(track.id, timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
queueFeedbackTimers.current.forEach((timer) => window.clearTimeout(timer));
|
||||||
|
queueFeedbackTimers.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchServerLibrary = async (config: ServerConfig): Promise<LoadedLibrary> => {
|
const fetchServerLibrary = async (config: ServerConfig): Promise<LoadedLibrary> => {
|
||||||
await requestSubsonic(config, "ping");
|
await requestSubsonic(config, "ping");
|
||||||
const [serverAlbums, serverSongs] = await Promise.all([
|
const [serverAlbums, serverSongs] = await Promise.all([
|
||||||
@@ -406,14 +434,15 @@ export default function App() {
|
|||||||
|
|
||||||
<section className="content-section track-section" aria-labelledby="tracks-heading">
|
<section className="content-section track-section" aria-labelledby="tracks-heading">
|
||||||
<div className="section-heading"><div><span className="eyebrow">{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : "A familiar thread"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}</h2></div><span>{visibleTracks.length} tracks</span></div>
|
<div className="section-heading"><div><span className="eyebrow">{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : "A familiar thread"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}</h2></div><span>{visibleTracks.length} tracks</span></div>
|
||||||
<div className="track-list" role="list">{visibleTracks.slice(0, view === "songs" ? visibleTracks.length : view === "home" && !query ? 6 : 30).map((track, index) => <div className={`track-row ${player.current.id === track.id ? "is-current" : ""}`} role="listitem" key={track.id}><button className="track-main" type="button" onClick={() => player.play(track)}><span className="track-number">{player.current.id === track.id && player.isPlaying ? <AudioLines size={15} /> : String(index + 1).padStart(2, "0")}</span><Cover album={track} size="small" /><span><strong>{track.title}</strong><small>{track.artist}</small></span></button><span className="track-album">{track.album}</span><IconButton label={favorites.has(track.id) ? `Remove ${track.title} from favorites` : `Add ${track.title} to favorites`} active={favorites.has(track.id)} onClick={() => toggleFavorite(track.id)}><Heart size={16} fill={favorites.has(track.id) ? "currentColor" : "none"} /></IconButton><span className="track-time">{formatTime(track.duration)}</span><IconButton label={`Add ${track.title} to queue`} onClick={() => player.addToQueue(track)}><Plus size={17} /></IconButton></div>)}</div>
|
<div className="track-list" role="list">{visibleTracks.slice(0, view === "songs" ? visibleTracks.length : view === "home" && !query ? 6 : 30).map((track, index) => { const justQueued = queuedTrackIds.has(track.id); return <div className={`track-row ${player.current.id === track.id ? "is-current" : ""}`} role="listitem" key={track.id}><button className="track-main" type="button" onClick={() => player.play(track)}><span className="track-number">{player.current.id === track.id && player.isPlaying ? <AudioLines size={15} /> : String(index + 1).padStart(2, "0")}</span><Cover album={track} size="small" /><span><strong>{track.title}</strong><small>{track.artist}</small></span></button><span className="track-album">{track.album}</span><IconButton label={favorites.has(track.id) ? `Remove ${track.title} from favorites` : `Add ${track.title} to favorites`} active={favorites.has(track.id)} onClick={() => toggleFavorite(track.id)}><Heart size={16} fill={favorites.has(track.id) ? "currentColor" : "none"} /></IconButton><span className="track-time">{formatTime(track.duration)}</span><IconButton label={justQueued ? `Add ${track.title} to queue again` : `Add ${track.title} to queue`} className={`queue-add-button ${justQueued ? "is-confirmed" : ""}`} onClick={() => addTrackToQueue(track)}>{justQueued ? <Check size={17} strokeWidth={2.5} /> : <Plus size={17} />}</IconButton></div>; })}</div>
|
||||||
|
<span className="sr-only" key={queueAnnouncement.key} role="status" aria-live="polite">{queueAnnouncement.message}</span>
|
||||||
</section>
|
</section>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{hasLibraryTracks && <aside className={`queue-panel ${queueOpen ? "is-open" : ""}`} aria-label="Play queue" aria-hidden={!queueOpen}><div className="queue-header"><div><span className="eyebrow">Up next</span><h2>Your queue</h2></div><IconButton label="Close queue" onClick={() => setQueueOpen(false)}><X size={19} /></IconButton></div><div className="queue-now"><Cover album={player.current} size="medium" /><span>Now playing</span><strong>{player.current.title}</strong><small>{player.current.artist}</small></div><div className="queue-list">{player.queue.map((track, index) => <div className="queue-item" key={`${track.id}-${index}`}><button type="button" onClick={() => player.play(track)}><span>{index + 1}</span><div><strong>{track.title}</strong><small>{track.artist}</small></div></button><IconButton label={`Remove ${track.title} from queue`} onClick={() => player.removeFromQueue(track.id)}><X size={15} /></IconButton></div>)}</div></aside>}
|
{hasLibraryTracks && <aside className={`queue-panel ${queueOpen ? "is-open" : ""}`} aria-label="Play queue" aria-hidden={!queueOpen}><div className="queue-header"><div><span className="eyebrow">Up next</span><h2>Your queue</h2></div><IconButton label="Close queue" onClick={() => setQueueOpen(false)}><X size={19} /></IconButton></div><div className="queue-now"><Cover album={player.current} size="medium" /><span>Now playing</span><strong>{player.current.title}</strong><small>{player.current.artist}</small></div><div className="queue-list">{player.queue.slice(0, queuePreviewLimit).map((track, index) => <div className="queue-item" key={`${track.id}-${index}`}><button type="button" onClick={() => player.play(track)}><span>{index + 1}</span><div><strong>{track.title}</strong><small>{track.artist}</small></div></button><IconButton label={`Remove ${track.title} from queue`} onClick={() => player.removeFromQueue(track.id)}><X size={15} /></IconButton></div>)}{player.queue.length > queuePreviewLimit && <p className="queue-remainder">{player.queue.length - queuePreviewLimit} more tracks will keep playing</p>}</div></aside>}
|
||||||
|
|
||||||
{hasLibraryTracks ? <footer className="player-bar">
|
{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>
|
<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>
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ const tracks = ["First", "Second", "Third", "Fourth"].map((title, index): Track
|
|||||||
coverTone: "indigo",
|
coverTone: "indigo",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const longLibrary = Array.from({ length: 18 }, (_, index): Track => ({
|
||||||
|
id: `long-${index + 1}`,
|
||||||
|
title: `Track ${index + 1}`,
|
||||||
|
artist: "Artist",
|
||||||
|
album: "Long Album",
|
||||||
|
albumId: "long-album",
|
||||||
|
duration: 180,
|
||||||
|
coverTone: "indigo",
|
||||||
|
}));
|
||||||
|
|
||||||
describe("usePlayer playback modes", () => {
|
describe("usePlayer playback modes", () => {
|
||||||
beforeAll(() => vi.stubGlobal("Audio", AudioMock));
|
beforeAll(() => vi.stubGlobal("Audio", AudioMock));
|
||||||
afterAll(() => vi.unstubAllGlobals());
|
afterAll(() => vi.unstubAllGlobals());
|
||||||
@@ -60,4 +70,22 @@ describe("usePlayer playback modes", () => {
|
|||||||
act(() => result.current.cycleRepeat());
|
act(() => result.current.cycleRepeat());
|
||||||
expect(result.current.repeatMode).toBe("off");
|
expect(result.current.repeatMode).toBe("off");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the complete loaded library available to skip forward", () => {
|
||||||
|
const { result } = renderHook(() => usePlayer(tracks));
|
||||||
|
|
||||||
|
act(() => result.current.replaceTracks(longLibrary));
|
||||||
|
expect(result.current.queue).toHaveLength(17);
|
||||||
|
|
||||||
|
for (let index = 0; index < 16; index += 1) {
|
||||||
|
act(() => result.current.next());
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.current.current.id).toBe("long-17");
|
||||||
|
expect(result.current.queue.map((track) => track.id)).toEqual(["long-18"]);
|
||||||
|
|
||||||
|
act(() => result.current.next());
|
||||||
|
expect(result.current.current.id).toBe("long-18");
|
||||||
|
expect(result.current.queue).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ export function usePlayer(initialTracks: Track[]) {
|
|||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const tracksRef = useRef(initialTracks);
|
const tracksRef = useRef(initialTracks);
|
||||||
const currentRef = useRef<Track>(initialTracks[0]);
|
const currentRef = useRef<Track>(initialTracks[0]);
|
||||||
const queueRef = useRef<Track[]>(initialTracks.slice(1, 5));
|
const queueRef = useRef<Track[]>(initialTracks.slice(1));
|
||||||
const historyRef = useRef<Track[]>([]);
|
const historyRef = useRef<Track[]>([]);
|
||||||
const shuffleRef = useRef(false);
|
const shuffleRef = useRef(false);
|
||||||
const repeatRef = useRef<RepeatMode>("off");
|
const repeatRef = useRef<RepeatMode>("off");
|
||||||
const [current, setCurrent] = useState<Track>(initialTracks[0]);
|
const [current, setCurrent] = useState<Track>(initialTracks[0]);
|
||||||
const [queue, setQueue] = useState<Track[]>(initialTracks.slice(1, 5));
|
const [queue, setQueue] = useState<Track[]>(initialTracks.slice(1));
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [duration, setDuration] = useState(initialTracks[0]?.duration ?? 0);
|
const [duration, setDuration] = useState(initialTracks[0]?.duration ?? 0);
|
||||||
@@ -214,7 +214,7 @@ export function usePlayer(initialTracks: Track[]) {
|
|||||||
tracksRef.current = items;
|
tracksRef.current = items;
|
||||||
historyRef.current = [];
|
historyRef.current = [];
|
||||||
commitCurrent(items[0]);
|
commitCurrent(items[0]);
|
||||||
commitQueue(shuffleRef.current ? shuffleTracks(items.slice(1, 8)) : items.slice(1, 8));
|
commitQueue(shuffleRef.current ? shuffleTracks(items.slice(1)) : items.slice(1));
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setDuration(items[0].duration);
|
setDuration(items[0].duration);
|
||||||
|
|||||||
+5
-1
@@ -147,6 +147,8 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut
|
|||||||
.track-time { color: var(--faint); font-size: 0.68rem; font-variant-numeric: tabular-nums; }
|
.track-time { color: var(--faint); font-size: 0.68rem; font-variant-numeric: tabular-nums; }
|
||||||
.track-row .icon-button { width: 29px; height: 29px; }
|
.track-row .icon-button { width: 29px; height: 29px; }
|
||||||
.track-row .icon-button.is-active { background: transparent; }
|
.track-row .icon-button.is-active { background: transparent; }
|
||||||
|
.track-row .queue-add-button.is-confirmed { color: var(--success); background: oklch(0.72 0.12 145 / 0.1); }
|
||||||
|
.queue-add-button.is-confirmed svg { animation: queue-confirm 180ms cubic-bezier(.22, 1, .36, 1); }
|
||||||
|
|
||||||
.empty-state { display: grid; min-height: 220px; place-items: center; align-content: center; border-top: 1px solid var(--line); color: var(--faint); text-align: center; }
|
.empty-state { display: grid; min-height: 220px; place-items: center; align-content: center; border-top: 1px solid var(--line); color: var(--faint); text-align: center; }
|
||||||
.empty-state h3 { margin: 12px 0 3px; color: var(--ink); font-size: 0.9rem; }
|
.empty-state h3 { margin: 12px 0 3px; color: var(--ink); font-size: 0.9rem; }
|
||||||
@@ -229,7 +231,7 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib
|
|||||||
.player-library-status strong { color: var(--ink); font-size: 0.76rem; }
|
.player-library-status strong { color: var(--ink); font-size: 0.76rem; }
|
||||||
.player-library-status small { max-width: 70vw; margin-top: 3px; color: var(--faint); font-size: 0.65rem; }
|
.player-library-status small { max-width: 70vw; margin-top: 3px; color: var(--faint); font-size: 0.65rem; }
|
||||||
|
|
||||||
.queue-panel { position: fixed; z-index: 45; top: 0; right: 0; bottom: var(--player-height); width: min(370px, 92vw); padding: 25px; border-left: 1px solid var(--line); background: oklch(0.17 0.01 275); box-shadow: -24px 0 70px oklch(0.05 0.016 275 / 0.4); transform: translateX(105%); transition: transform 240ms cubic-bezier(.22, 1, .36, 1); }
|
.queue-panel { position: fixed; z-index: 45; top: 0; right: 0; bottom: var(--player-height); width: min(370px, 92vw); overflow-y: auto; padding: 25px; border-left: 1px solid var(--line); background: oklch(0.17 0.01 275); box-shadow: -24px 0 70px oklch(0.05 0.016 275 / 0.4); transform: translateX(105%); transition: transform 240ms cubic-bezier(.22, 1, .36, 1); }
|
||||||
.queue-panel.is-open { transform: translateX(0); }
|
.queue-panel.is-open { transform: translateX(0); }
|
||||||
.queue-header { display: flex; align-items: flex-start; justify-content: space-between; }
|
.queue-header { display: flex; align-items: flex-start; justify-content: space-between; }
|
||||||
.queue-header h2 { margin: 5px 0 0; font-size: 1.35rem; letter-spacing: -0.04em; }
|
.queue-header h2 { margin: 5px 0 0; font-size: 1.35rem; letter-spacing: -0.04em; }
|
||||||
@@ -245,11 +247,13 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib
|
|||||||
.queue-item strong, .queue-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.queue-item strong, .queue-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.queue-item strong { font-size: 0.72rem; }
|
.queue-item strong { font-size: 0.72rem; }
|
||||||
.queue-item small { margin-top: 3px; color: var(--faint); font-size: 0.63rem; }
|
.queue-item small { margin-top: 3px; color: var(--faint); font-size: 0.63rem; }
|
||||||
|
.queue-remainder { margin: 0; padding: 15px 0; color: var(--faint); font-size: 0.68rem; text-align: center; }
|
||||||
|
|
||||||
.nav-scrim { display: none; }
|
.nav-scrim { display: none; }
|
||||||
@keyframes pulse { 50% { opacity: 0.45; transform: scale(0.82); } }
|
@keyframes pulse { 50% { opacity: 0.45; transform: scale(0.82); } }
|
||||||
@keyframes scan-turn { to { transform: rotate(360deg); } }
|
@keyframes scan-turn { to { transform: rotate(360deg); } }
|
||||||
@keyframes scan-pulse { 50% { opacity: 0.3; transform: translateY(-2px); } }
|
@keyframes scan-pulse { 50% { opacity: 0.3; transform: translateY(-2px); } }
|
||||||
|
@keyframes queue-confirm { from { opacity: 0.45; transform: scale(0.62); } to { opacity: 1; transform: scale(1); } }
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
@media (max-width: 1080px) {
|
||||||
.album-grid { grid-template-columns: repeat(4, minmax(120px, 1fr)); }
|
.album-grid { grid-template-columns: repeat(4, minmax(120px, 1fr)); }
|
||||||
|
|||||||
Reference in New Issue
Block a user