Gate launch until library restoration completes, make playback context-aware with opt-in shuffle and ordered album/artist queues, and show Heavy Rotation only after meaningful repeat listens.
Hop-State: A_06FNQ6AXS34K9AR7VB49SNR Hop-Proposal: R_06FNQ6AARMBP75PB7NYVYC8 Hop-Task: T_06FNQ3V4MZHA2TBQB6RT1V0 Hop-Attempt: AT_06FNQ3V4MZRVJ31NQZ8K7DG
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const managedServer = vi.hoisted(() => ({
|
||||||
|
chooseMusicFolder: vi.fn(),
|
||||||
|
getManagedServerStatus: vi.fn(),
|
||||||
|
getSavedCredentials: vi.fn(),
|
||||||
|
isDesktopRuntime: vi.fn(),
|
||||||
|
saveRemotePassword: vi.fn(),
|
||||||
|
startManagedServer: vi.fn(),
|
||||||
|
stopManagedServer: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./lib/managedServer", () => managedServer);
|
||||||
|
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((done) => { resolve = done; });
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("App bootstrap", () => {
|
||||||
|
beforeAll(() => vi.stubGlobal("Audio", AudioMock));
|
||||||
|
afterAll(() => vi.unstubAllGlobals());
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
managedServer.isDesktopRuntime.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a saved library behind the launch gate while credentials load", () => {
|
||||||
|
localStorage.setItem("resonant.sourceMode", "remote");
|
||||||
|
localStorage.setItem("resonant.serverUrl", "https://music.example.test");
|
||||||
|
localStorage.setItem("resonant.username", "listener");
|
||||||
|
managedServer.getManagedServerStatus.mockReturnValue(new Promise(() => undefined));
|
||||||
|
managedServer.getSavedCredentials.mockReturnValue(new Promise(() => undefined));
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Opening your library")).not.toBeNull();
|
||||||
|
expect(screen.queryByText("Let it find you again.")).toBeNull();
|
||||||
|
expect(screen.queryByText("Afterimage")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals the demo only after bootstrap confirms there is no saved library", async () => {
|
||||||
|
const status = deferred<{ running: boolean; url: string; lanUrls: string[]; version: string }>();
|
||||||
|
const credentials = deferred<Record<string, never>>();
|
||||||
|
managedServer.getManagedServerStatus.mockReturnValue(status.promise);
|
||||||
|
managedServer.getSavedCredentials.mockReturnValue(credentials.promise);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Opening your library")).not.toBeNull();
|
||||||
|
expect(screen.queryByText("Let it find you again.")).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
status.resolve({ running: false, url: "http://127.0.0.1:4533", lanUrls: [], version: "0.61.2" });
|
||||||
|
credentials.resolve({});
|
||||||
|
await Promise.all([status.promise, credentials.promise]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText("Let it find you again.")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+66
-13
@@ -34,7 +34,7 @@ import {
|
|||||||
import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo";
|
import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo";
|
||||||
import { usePlayer } from "./hooks/usePlayer";
|
import { usePlayer } from "./hooks/usePlayer";
|
||||||
import { buildArtistSummaries, type ArtistSummary } from "./lib/artists";
|
import { buildArtistSummaries, type ArtistSummary } from "./lib/artists";
|
||||||
import { orderAlbumsByRecentlyAdded, orderTracksByPlayCount, pickRandomTrack } from "./lib/libraryOrdering";
|
import { orderAlbumsByRecentlyAdded, orderAlbumTracks, orderTracksByPlayCount, pickRandomTrack, selectHeavyRotation } from "./lib/libraryOrdering";
|
||||||
import { fetchAllPages, pollForLibrary } from "./lib/librarySync";
|
import { fetchAllPages, pollForLibrary } from "./lib/librarySync";
|
||||||
import {
|
import {
|
||||||
chooseMusicFolder,
|
chooseMusicFolder,
|
||||||
@@ -95,7 +95,7 @@ const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface ServerAlbum { id: string; name: string; artist?: string; year?: number; coverArt?: string; created?: string }
|
interface ServerAlbum { id: string; name: string; artist?: string; year?: number; coverArt?: string; created?: string }
|
||||||
interface ServerSong { id: string; title: string; artist?: string; album?: string; albumId?: string; duration?: number; coverArt?: string; starred?: string; playCount?: number }
|
interface ServerSong { id: string; title: string; artist?: string; album?: string; albumId?: string; duration?: number; coverArt?: string; starred?: string; playCount?: number; discNumber?: number; track?: number }
|
||||||
interface LoadedLibrary { albums: Album[]; tracks: Track[] }
|
interface LoadedLibrary { albums: Album[]; tracks: Track[] }
|
||||||
type LibraryPhase = "ready" | "scanning" | "empty";
|
type LibraryPhase = "ready" | "scanning" | "empty";
|
||||||
|
|
||||||
@@ -117,6 +117,7 @@ export default function App() {
|
|||||||
const [favoriteAnnouncement, setFavoriteAnnouncement] = useState({ key: 0, message: "" });
|
const [favoriteAnnouncement, setFavoriteAnnouncement] = useState({ key: 0, message: "" });
|
||||||
const [favoriteError, setFavoriteError] = useState<string | null>(null);
|
const [favoriteError, setFavoriteError] = useState<string | null>(null);
|
||||||
const favoriteErrorTimer = useRef<number | null>(null);
|
const favoriteErrorTimer = useRef<number | null>(null);
|
||||||
|
const [isBootstrapping, setIsBootstrapping] = useState(true);
|
||||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("demo");
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("demo");
|
||||||
const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library");
|
const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library");
|
||||||
const [libraryPhase, setLibraryPhase] = useState<LibraryPhase>("ready");
|
const [libraryPhase, setLibraryPhase] = useState<LibraryPhase>("ready");
|
||||||
@@ -163,10 +164,15 @@ export default function App() {
|
|||||||
return result;
|
return result;
|
||||||
}, [favorites, libraryTracks, query, view]);
|
}, [favorites, libraryTracks, query, view]);
|
||||||
|
|
||||||
|
const heavyRotationTracks = useMemo(() => selectHeavyRotation(libraryTracks), [libraryTracks]);
|
||||||
|
const hasHeavyRotation = heavyRotationTracks.length > 0;
|
||||||
|
|
||||||
const displayedTracks = useMemo(() => {
|
const displayedTracks = useMemo(() => {
|
||||||
const tracks = view === "home" ? orderTracksByPlayCount(visibleTracks) : visibleTracks;
|
const tracks = view === "home"
|
||||||
|
? query ? orderTracksByPlayCount(visibleTracks) : heavyRotationTracks
|
||||||
|
: visibleTracks;
|
||||||
return tracks.slice(0, view === "songs" ? tracks.length : view === "home" && !query ? 6 : 30);
|
return tracks.slice(0, view === "songs" ? tracks.length : view === "home" && !query ? 6 : 30);
|
||||||
}, [query, view, visibleTracks]);
|
}, [heavyRotationTracks, query, view, visibleTracks]);
|
||||||
|
|
||||||
const recentlyAddedAlbums = useMemo(() => orderAlbumsByRecentlyAdded(libraryAlbums), [libraryAlbums]);
|
const recentlyAddedAlbums = useMemo(() => orderAlbumsByRecentlyAdded(libraryAlbums), [libraryAlbums]);
|
||||||
|
|
||||||
@@ -175,6 +181,16 @@ export default function App() {
|
|||||||
return needle ? recentlyAddedAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : recentlyAddedAlbums;
|
return needle ? recentlyAddedAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : recentlyAddedAlbums;
|
||||||
}, [query, recentlyAddedAlbums]);
|
}, [query, recentlyAddedAlbums]);
|
||||||
|
|
||||||
|
const tracksByAlbum = useMemo(() => {
|
||||||
|
const grouped = new Map<string, Track[]>();
|
||||||
|
libraryTracks.forEach((track) => {
|
||||||
|
const albumTracks = grouped.get(track.albumId);
|
||||||
|
if (albumTracks) albumTracks.push(track); else grouped.set(track.albumId, [track]);
|
||||||
|
});
|
||||||
|
grouped.forEach((tracks, albumId) => grouped.set(albumId, orderAlbumTracks(tracks)));
|
||||||
|
return grouped;
|
||||||
|
}, [libraryTracks]);
|
||||||
|
|
||||||
const artists = useMemo(() => buildArtistSummaries(libraryTracks), [libraryTracks]);
|
const artists = useMemo(() => buildArtistSummaries(libraryTracks), [libraryTracks]);
|
||||||
|
|
||||||
const visibleArtists = useMemo(() => {
|
const visibleArtists = useMemo(() => {
|
||||||
@@ -299,6 +315,8 @@ export default function App() {
|
|||||||
streamUrl: streamUrl(config, song.id),
|
streamUrl: streamUrl(config, song.id),
|
||||||
favorite: Boolean(song.starred),
|
favorite: Boolean(song.starred),
|
||||||
playCount: song.playCount ?? 0,
|
playCount: song.playCount ?? 0,
|
||||||
|
discNumber: song.discNumber,
|
||||||
|
trackNumber: song.track,
|
||||||
}));
|
}));
|
||||||
return { albums, tracks };
|
return { albums, tracks };
|
||||||
};
|
};
|
||||||
@@ -396,6 +414,11 @@ export default function App() {
|
|||||||
setConnectionMessage("Your local library is ready");
|
setConnectionMessage("Your local library is ready");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setManagedStatus(await getManagedServerStatus().catch(() => null));
|
setManagedStatus(await getManagedServerStatus().catch(() => null));
|
||||||
|
setLibraryAlbums([]);
|
||||||
|
setLibraryTracks([]);
|
||||||
|
setSpotlightTrackId("");
|
||||||
|
commitFavorites(new Set());
|
||||||
|
player.clearTracks();
|
||||||
setLibraryPhase("empty");
|
setLibraryPhase("empty");
|
||||||
setConnectionStatus("error");
|
setConnectionStatus("error");
|
||||||
setConnectionMessage(error instanceof Error ? error.message : "Your local library could not start.");
|
setConnectionMessage(error instanceof Error ? error.message : "Your local library could not start.");
|
||||||
@@ -428,6 +451,12 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const restoreLibrary = async () => {
|
const restoreLibrary = async () => {
|
||||||
|
const savedMode = localStorage.getItem("resonant.sourceMode");
|
||||||
|
let restoringSavedLibrary = savedMode === "local"
|
||||||
|
? Boolean(localStorage.getItem("resonant.musicFolder"))
|
||||||
|
: savedMode === "remote"
|
||||||
|
? Boolean(server.url && server.username)
|
||||||
|
: false;
|
||||||
try {
|
try {
|
||||||
const [status, credentials] = await Promise.all([
|
const [status, credentials] = await Promise.all([
|
||||||
getManagedServerStatus(),
|
getManagedServerStatus(),
|
||||||
@@ -442,16 +471,29 @@ export default function App() {
|
|||||||
setLocalSetup({ musicFolder, password: localPassword });
|
setLocalSetup({ musicFolder, password: localPassword });
|
||||||
setServer((current) => ({ ...current, password: remotePassword }));
|
setServer((current) => ({ ...current, password: remotePassword }));
|
||||||
|
|
||||||
const savedMode = localStorage.getItem("resonant.sourceMode");
|
if (savedMode === "local" && musicFolder) {
|
||||||
if (savedMode === "local" && musicFolder && localPassword) {
|
restoringSavedLibrary = true;
|
||||||
|
if (!localPassword) throw new Error("Your saved library password is unavailable. Open Library source to reconnect it.");
|
||||||
await runLocalLibrary(musicFolder, localPassword);
|
await runLocalLibrary(musicFolder, localPassword);
|
||||||
} else if (savedMode === "remote" && server.url && server.username && remotePassword) {
|
} else if (savedMode === "remote" && server.url && server.username) {
|
||||||
|
restoringSavedLibrary = true;
|
||||||
|
if (!remotePassword) throw new Error("Your saved server password is unavailable. Open Library source to reconnect it.");
|
||||||
await loadServerLibrary({ ...server, password: remotePassword }, `Connected as ${server.username}`);
|
await loadServerLibrary({ ...server, password: remotePassword }, `Connected as ${server.username}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
if (restoringSavedLibrary) {
|
||||||
|
setLibraryAlbums([]);
|
||||||
|
setLibraryTracks([]);
|
||||||
|
setSpotlightTrackId("");
|
||||||
|
commitFavorites(new Set());
|
||||||
|
player.clearTracks();
|
||||||
|
setLibraryPhase("empty");
|
||||||
|
}
|
||||||
setConnectionStatus("error");
|
setConnectionStatus("error");
|
||||||
setConnectionMessage(error instanceof Error ? error.message : "Your saved library could not be restored.");
|
setConnectionMessage(error instanceof Error ? error.message : "Your saved library could not be restored.");
|
||||||
|
} finally {
|
||||||
|
if (active) setIsBootstrapping(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void restoreLibrary();
|
void restoreLibrary();
|
||||||
@@ -469,6 +511,17 @@ export default function App() {
|
|||||||
if (next !== "favorites") setQuery("");
|
if (next !== "favorites") setQuery("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isBootstrapping) {
|
||||||
|
return (
|
||||||
|
<div className="app-bootstrap" role="status" aria-live="polite">
|
||||||
|
<div className="brand"><div className="brand-mark"><AudioLines size={19} /></div><span>Resonant</span></div>
|
||||||
|
<div className="library-status-icon is-scanning"><Disc3 size={27} /></div>
|
||||||
|
<strong>Opening your library</strong>
|
||||||
|
<span>Restoring your saved connection</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<a className="skip-link" href="#main-content">Skip to library</a>
|
<a className="skip-link" href="#main-content">Skip to library</a>
|
||||||
@@ -543,7 +596,7 @@ export default function App() {
|
|||||||
) : <>
|
) : <>
|
||||||
{hasLibraryTracks && spotlightTrack && view === "home" && !query && (
|
{hasLibraryTracks && spotlightTrack && view === "home" && !query && (
|
||||||
<section className="hero" aria-labelledby="hero-title">
|
<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><strong>{spotlightTrack.title}</strong> by {spotlightTrack.artist} surfaced from your library. Start here, then let the queue wander.</p><div className="hero-actions"><button className="button-primary button-primary--light" type="button" onClick={() => spotlightIsPlaying ? player.toggle() : player.play(spotlightTrack)}>{spotlightIsPlaying ? <Pause size={17} fill="currentColor" /> : <Play size={17} fill="currentColor" />} {spotlightIsPlaying ? "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-copy"><span className="eyebrow">Picked from your library</span><h1 id="hero-title">Let it find you again.</h1><p><strong>{spotlightTrack.title}</strong> by {spotlightTrack.artist} surfaced from your library. Start here, then let the queue wander.</p><div className="hero-actions"><button className="button-primary button-primary--light" type="button" onClick={() => spotlightIsPlaying ? player.toggle() : player.playCollection([spotlightTrack, ...libraryTracks.filter((track) => track.id !== spotlightTrack.id)])}>{spotlightIsPlaying ? <Pause size={17} fill="currentColor" /> : <Play size={17} fill="currentColor" />} {spotlightIsPlaying ? "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={spotlightTrack} size="large" /><div className="orbit orbit-one" /><div className="orbit orbit-two" /></div>
|
<div className="hero-art" aria-hidden="true"><Cover album={spotlightTrack} size="large" /><div className="orbit orbit-one" /><div className="orbit orbit-two" /></div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -554,14 +607,14 @@ export default function App() {
|
|||||||
</section> : <>
|
</section> : <>
|
||||||
{view !== "songs" && view !== "favorites" && <section className="content-section" aria-labelledby="albums-heading">
|
{view !== "songs" && view !== "favorites" && <section className="content-section" aria-labelledby="albums-heading">
|
||||||
<div className="section-heading"><div><span className="eyebrow">{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Newest in your library"}</span><h2 id="albums-heading">{query || view === "albums" ? "Albums" : "Recently added"}</h2></div><button type="button" onClick={() => goTo("albums")}>View all <ChevronRight size={16} /></button></div>
|
<div className="section-heading"><div><span className="eyebrow">{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Newest in your library"}</span><h2 id="albums-heading">{query || view === "albums" ? "Albums" : "Recently added"}</h2></div><button type="button" onClick={() => goTo("albums")}>View all <ChevronRight size={16} /></button></div>
|
||||||
{visibleAlbums.length ? <div className="album-grid">{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return <article className="album-tile" key={album.id}><button className="cover-button" type="button" aria-label={`Play ${album.title}`} onClick={() => firstTrack && player.play(firstTrack)}><Cover album={album} /><span className="cover-play"><Play size={20} fill="currentColor" /></span></button><div><h3>{album.title}</h3><p>{album.artist} · {album.year || "Unknown year"}</p></div><IconButton label={`More options for ${album.title}`}><Ellipsis size={18} /></IconButton></article>; })}</div> : <div className="empty-state"><Search size={26} /><h3>No albums found</h3><p>Try a title, artist, or track name.</p></div>}
|
{visibleAlbums.length ? <div className="album-grid">{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const albumTracks = tracksByAlbum.get(album.id) ?? []; return <article className="album-tile" key={album.id}><button className="cover-button" type="button" aria-label={`Play ${album.title}`} onClick={() => player.playCollection(albumTracks)}><Cover album={album} /><span className="cover-play"><Play size={20} fill="currentColor" /></span></button><div><h3>{album.title}</h3><p>{album.artist} · {album.year || "Unknown year"}</p></div><IconButton label={`More options for ${album.title}`}><Ellipsis size={18} /></IconButton></article>; })}</div> : <div className="empty-state"><Search size={26} /><h3>No albums found</h3><p>Try a title, artist, or track name.</p></div>}
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
<section className="content-section track-section" aria-labelledby="tracks-heading">
|
{view !== "albums" && <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" : query ? "Matching your search" : "Most played by listen count"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}</h2></div><span>{view === "home" && !query ? "By play count" : `${visibleTracks.length} tracks`}</span></div>
|
<div className="section-heading"><div><span className="eyebrow">{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : query ? "Matching your search" : "Most played by listen count"}</span><h2 id="tracks-heading">{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}</h2></div><span>{view === "home" && !query ? hasHeavyRotation ? "By play count" : "Still learning" : `${visibleTracks.length} tracks`}</span></div>
|
||||||
{view === "favorites" && visibleTracks.length === 0 ? <div className="empty-state"><Heart size={26} /><h3>No favorite tracks yet</h3><p>Use the heart beside a song to keep it here.</p></div> : <div className="track-list" role="list">{displayedTracks.map((track, index) => { const justQueued = queuedTrackIds.has(track.id); const favoritePending = favoritePendingIds.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={favoritePending ? `Saving favorite for ${track.title}` : favorites.has(track.id) ? `Remove ${track.title} from favorites` : `Add ${track.title} to favorites`} active={favorites.has(track.id)} disabled={favoritePending} onClick={() => void toggleFavorite(track)}><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>}
|
{view === "home" && !query && !hasHeavyRotation ? <div className="empty-state"><AudioLines size={26} /><h3>Heavy rotation is still taking shape</h3><p>Finish a few songs more than once and your most-played tracks will appear here.</p></div> : view === "favorites" && visibleTracks.length === 0 ? <div className="empty-state"><Heart size={26} /><h3>No favorite tracks yet</h3><p>Use the heart beside a song to keep it here.</p></div> : <div className="track-list" role="list">{displayedTracks.map((track, index) => { const justQueued = queuedTrackIds.has(track.id); const favoritePending = favoritePendingIds.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.playFromContext(track, displayedTracks)}><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={favoritePending ? `Saving favorite for ${track.title}` : favorites.has(track.id) ? `Remove ${track.title} from favorites` : `Add ${track.title} to favorites`} active={favorites.has(track.id)} disabled={favoritePending} onClick={() => void toggleFavorite(track)}><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>
|
<span className="sr-only" key={queueAnnouncement.key} role="status" aria-live="polite">{queueAnnouncement.message}</span>
|
||||||
</section>
|
</section>}
|
||||||
</>}
|
</>}
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+8
-8
@@ -10,14 +10,14 @@ export const albums: Album[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const tracks: Track[] = [
|
export const tracks: Track[] = [
|
||||||
{ id: "t1", title: "Still Life in Motion", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 238, coverTone: "ember", favorite: true, playCount: 12 },
|
{ id: "t1", title: "Still Life in Motion", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 238, coverTone: "ember", favorite: true, discNumber: 1, trackNumber: 1 },
|
||||||
{ id: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss", playCount: 27 },
|
{ id: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss", discNumber: 1, trackNumber: 1 },
|
||||||
{ id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true, playCount: 18 },
|
{ id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true, discNumber: 1, trackNumber: 1 },
|
||||||
{ id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay", playCount: 5 },
|
{ id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay", discNumber: 1, trackNumber: 1 },
|
||||||
{ id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo", playCount: 34 },
|
{ id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo", discNumber: 1, trackNumber: 1 },
|
||||||
{ id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand", playCount: 2 },
|
{ id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand", discNumber: 1, trackNumber: 1 },
|
||||||
{ id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember", playCount: 21 },
|
{ id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember", discNumber: 1, trackNumber: 2 },
|
||||||
{ id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss", playCount: 9 },
|
{ id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss", discNumber: 1, trackNumber: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const formatTime = (seconds: number) => {
|
export const formatTime = (seconds: number) => {
|
||||||
|
|||||||
@@ -80,6 +80,41 @@ describe("usePlayer playback modes", () => {
|
|||||||
expect(result.current.repeatMode).toBe("off");
|
expect(result.current.repeatMode).toBe("off");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts an album or artist context in its supplied order and turns shuffle off", () => {
|
||||||
|
const { result } = renderHook(() => usePlayer(tracks));
|
||||||
|
|
||||||
|
act(() => result.current.toggleShuffle());
|
||||||
|
expect(result.current.isShuffled).toBe(true);
|
||||||
|
|
||||||
|
const collection = [tracks[2], tracks[0], tracks[3]];
|
||||||
|
act(() => result.current.playCollection(collection));
|
||||||
|
|
||||||
|
expect(result.current.isShuffled).toBe(false);
|
||||||
|
expect(result.current.current.id).toBe(tracks[2].id);
|
||||||
|
expect(result.current.queue.map((track) => track.id)).toEqual([tracks[0].id, tracks[3].id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays a selected song followed only by the rest of its visible context", () => {
|
||||||
|
const { result } = renderHook(() => usePlayer(tracks));
|
||||||
|
|
||||||
|
act(() => result.current.playFromContext(tracks[1], tracks));
|
||||||
|
|
||||||
|
expect(result.current.current.id).toBe(tracks[1].id);
|
||||||
|
expect(result.current.queue.map((track) => track.id)).toEqual([tracks[2].id, tracks[3].id]);
|
||||||
|
expect(result.current.isShuffled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the original context order when shuffle is turned back off", () => {
|
||||||
|
const { result } = renderHook(() => usePlayer(tracks));
|
||||||
|
const originalQueue = result.current.queue.map((track) => track.id);
|
||||||
|
|
||||||
|
act(() => result.current.toggleShuffle());
|
||||||
|
expect(result.current.queue.map((track) => track.id)).not.toEqual(originalQueue);
|
||||||
|
|
||||||
|
act(() => result.current.toggleShuffle());
|
||||||
|
expect(result.current.queue.map((track) => track.id)).toEqual(originalQueue);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the complete loaded library available to skip forward", () => {
|
it("keeps the complete loaded library available to skip forward", () => {
|
||||||
const { result } = renderHook(() => usePlayer(tracks));
|
const { result } = renderHook(() => usePlayer(tracks));
|
||||||
|
|
||||||
|
|||||||
+69
-30
@@ -2,12 +2,18 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { nextRepeatMode, shuffleTracks, type RepeatMode } from "../lib/playback";
|
import { nextRepeatMode, shuffleTracks, type RepeatMode } from "../lib/playback";
|
||||||
import type { Track } from "../types";
|
import type { Track } from "../types";
|
||||||
|
|
||||||
|
function removeFirstTrack(items: Track[], id: string): Track[] {
|
||||||
|
const index = items.findIndex((track) => track.id === id);
|
||||||
|
return index < 0 ? items : [...items.slice(0, index), ...items.slice(index + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Track) => void) {
|
export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Track) => void) {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const onTrackCompletedRef = useRef(onTrackCompleted);
|
const onTrackCompletedRef = useRef(onTrackCompleted);
|
||||||
const tracksRef = useRef(initialTracks);
|
const contextRef = useRef(initialTracks);
|
||||||
const currentRef = useRef<Track>(initialTracks[0]);
|
const currentRef = useRef<Track>(initialTracks[0]);
|
||||||
const queueRef = useRef<Track[]>(initialTracks.slice(1));
|
const queueRef = useRef<Track[]>(initialTracks.slice(1));
|
||||||
|
const unshuffledQueueRef = 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");
|
||||||
@@ -32,6 +38,11 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
|||||||
setQueue(items);
|
setQueue(items);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const commitShuffle = useCallback((enabled: boolean) => {
|
||||||
|
shuffleRef.current = enabled;
|
||||||
|
setIsShuffled(enabled);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const restartCurrent = useCallback((resume: boolean) => {
|
const restartCurrent = useCallback((resume: boolean) => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
@@ -53,13 +64,15 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
|||||||
|
|
||||||
if (nextTrack) {
|
if (nextTrack) {
|
||||||
historyRef.current = [...historyRef.current, currentTrack];
|
historyRef.current = [...historyRef.current, currentTrack];
|
||||||
|
unshuffledQueueRef.current = removeFirstTrack(unshuffledQueueRef.current, nextTrack.id);
|
||||||
commitQueue(queueRef.current.slice(1));
|
commitQueue(queueRef.current.slice(1));
|
||||||
commitCurrent(nextTrack);
|
commitCurrent(nextTrack);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repeatRef.current === "all") {
|
if (repeatRef.current === "all") {
|
||||||
let cycle = [...historyRef.current, currentTrack];
|
const orderedContext = contextRef.current.length ? contextRef.current : [...historyRef.current, currentTrack];
|
||||||
|
let cycle = [...orderedContext];
|
||||||
if (shuffleRef.current) {
|
if (shuffleRef.current) {
|
||||||
cycle = shuffleTracks(cycle);
|
cycle = shuffleTracks(cycle);
|
||||||
if (cycle.length > 1 && cycle[0].id === currentTrack.id) {
|
if (cycle.length > 1 && cycle[0].id === currentTrack.id) {
|
||||||
@@ -75,6 +88,15 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
|||||||
|
|
||||||
commitCurrent(cycle[0]);
|
commitCurrent(cycle[0]);
|
||||||
commitQueue(cycle.slice(1));
|
commitQueue(cycle.slice(1));
|
||||||
|
if (shuffleRef.current) {
|
||||||
|
const originalIndex = orderedContext.findIndex((track) => track.id === cycle[0].id);
|
||||||
|
unshuffledQueueRef.current = [
|
||||||
|
...orderedContext.slice(originalIndex + 1),
|
||||||
|
...orderedContext.slice(0, originalIndex),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
unshuffledQueueRef.current = cycle.slice(1);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,28 +143,46 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queueIndex = queueRef.current.findIndex((queuedTrack) => queuedTrack.id === track.id);
|
||||||
|
const skippedTracks = queueIndex >= 0 ? queueRef.current.slice(0, queueIndex) : [];
|
||||||
|
const upcoming = queueIndex >= 0
|
||||||
|
? queueRef.current.slice(queueIndex + 1)
|
||||||
|
: queueRef.current.filter((queuedTrack) => queuedTrack.id !== track.id);
|
||||||
|
const consumedIds = new Set([...skippedTracks, track].map((item) => item.id));
|
||||||
|
|
||||||
historyRef.current = [...historyRef.current, currentRef.current];
|
historyRef.current = [...historyRef.current, currentRef.current];
|
||||||
commitQueue(queueRef.current.filter((queuedTrack) => queuedTrack.id !== track.id));
|
unshuffledQueueRef.current = shuffleRef.current
|
||||||
|
? unshuffledQueueRef.current.filter((queuedTrack) => !consumedIds.has(queuedTrack.id))
|
||||||
|
: upcoming;
|
||||||
|
contextRef.current = [track, ...upcoming];
|
||||||
|
commitQueue(upcoming);
|
||||||
commitCurrent(track);
|
commitCurrent(track);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}, [commitCurrent, commitQueue]);
|
}, [commitCurrent, commitQueue]);
|
||||||
|
|
||||||
const playCollection = useCallback((items: Track[]) => {
|
const playFromContext = useCallback((track: Track, items: Track[]) => {
|
||||||
const firstTrack = items[0];
|
const trackIndex = items.findIndex((item) => item.id === track.id);
|
||||||
if (!firstTrack) return;
|
const context = trackIndex >= 0 ? [...items] : [track];
|
||||||
|
const upcoming = trackIndex >= 0 ? items.slice(trackIndex + 1) : [];
|
||||||
|
|
||||||
historyRef.current = [];
|
historyRef.current = [];
|
||||||
const upcoming = shuffleRef.current ? shuffleTracks(items.slice(1)) : items.slice(1);
|
contextRef.current = context;
|
||||||
|
unshuffledQueueRef.current = upcoming;
|
||||||
|
commitShuffle(false);
|
||||||
commitQueue(upcoming);
|
commitQueue(upcoming);
|
||||||
|
|
||||||
if (firstTrack.id === currentRef.current.id) {
|
if (track.id === currentRef.current.id) {
|
||||||
void audioRef.current?.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
|
void audioRef.current?.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
commitCurrent(firstTrack);
|
commitCurrent(track);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}, [commitCurrent, commitQueue]);
|
}, [commitCurrent, commitQueue, commitShuffle]);
|
||||||
|
|
||||||
|
const playCollection = useCallback((items: Track[]) => {
|
||||||
|
if (items[0]) playFromContext(items[0], items);
|
||||||
|
}, [playFromContext]);
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
@@ -177,35 +217,26 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
|||||||
const previousTrack = historyRef.current.at(-1);
|
const previousTrack = historyRef.current.at(-1);
|
||||||
if (!previousTrack) return;
|
if (!previousTrack) return;
|
||||||
historyRef.current = historyRef.current.slice(0, -1);
|
historyRef.current = historyRef.current.slice(0, -1);
|
||||||
|
unshuffledQueueRef.current = [currentRef.current, ...unshuffledQueueRef.current];
|
||||||
commitQueue([currentRef.current, ...queueRef.current]);
|
commitQueue([currentRef.current, ...queueRef.current]);
|
||||||
commitCurrent(previousTrack);
|
commitCurrent(previousTrack);
|
||||||
}, [commitCurrent, commitQueue]);
|
}, [commitCurrent, commitQueue]);
|
||||||
|
|
||||||
const toggleShuffle = useCallback(() => {
|
const toggleShuffle = useCallback(() => {
|
||||||
const nextShuffled = !shuffleRef.current;
|
const nextShuffled = !shuffleRef.current;
|
||||||
shuffleRef.current = nextShuffled;
|
commitShuffle(nextShuffled);
|
||||||
setIsShuffled(nextShuffled);
|
|
||||||
|
|
||||||
let upcoming = queueRef.current;
|
|
||||||
if (nextShuffled) {
|
if (nextShuffled) {
|
||||||
if (upcoming.length === 0) {
|
const shuffled = shuffleTracks(unshuffledQueueRef.current);
|
||||||
const playedIds = new Set([...historyRef.current, currentRef.current].map((track) => track.id));
|
if (shuffled.length > 1 && shuffled.every((track, index) => track.id === unshuffledQueueRef.current[index].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()!);
|
shuffled.push(shuffled.shift()!);
|
||||||
}
|
}
|
||||||
commitQueue(shuffled);
|
commitQueue(shuffled);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryOrder = new Map(tracksRef.current.map((track, index) => [track.id, index]));
|
commitQueue(unshuffledQueueRef.current);
|
||||||
commitQueue(upcoming
|
}, [commitQueue, commitShuffle]);
|
||||||
.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 cycleRepeat = useCallback(() => {
|
||||||
const nextMode = nextRepeatMode(repeatRef.current);
|
const nextMode = nextRepeatMode(repeatRef.current);
|
||||||
@@ -214,34 +245,41 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addToQueue = useCallback((track: Track) => {
|
const addToQueue = useCallback((track: Track) => {
|
||||||
|
contextRef.current = [...contextRef.current, track];
|
||||||
|
unshuffledQueueRef.current = [...unshuffledQueueRef.current, track];
|
||||||
commitQueue([...queueRef.current, track]);
|
commitQueue([...queueRef.current, track]);
|
||||||
}, [commitQueue]);
|
}, [commitQueue]);
|
||||||
|
|
||||||
const removeFromQueue = useCallback((id: string) => {
|
const removeFromQueue = useCallback((id: string) => {
|
||||||
|
unshuffledQueueRef.current = unshuffledQueueRef.current.filter((track) => track.id !== id);
|
||||||
commitQueue(queueRef.current.filter((track) => track.id !== id));
|
commitQueue(queueRef.current.filter((track) => track.id !== id));
|
||||||
}, [commitQueue]);
|
}, [commitQueue]);
|
||||||
|
|
||||||
const clearTracks = useCallback(() => {
|
const clearTracks = useCallback(() => {
|
||||||
audioRef.current?.pause();
|
audioRef.current?.pause();
|
||||||
tracksRef.current = [];
|
contextRef.current = [];
|
||||||
|
unshuffledQueueRef.current = [];
|
||||||
historyRef.current = [];
|
historyRef.current = [];
|
||||||
commitQueue([]);
|
commitQueue([]);
|
||||||
|
commitShuffle(false);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
}, [commitQueue]);
|
}, [commitQueue, commitShuffle]);
|
||||||
|
|
||||||
const replaceTracks = useCallback((items: Track[]) => {
|
const replaceTracks = useCallback((items: Track[]) => {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
audioRef.current?.pause();
|
audioRef.current?.pause();
|
||||||
tracksRef.current = items;
|
contextRef.current = items;
|
||||||
|
unshuffledQueueRef.current = items.slice(1);
|
||||||
historyRef.current = [];
|
historyRef.current = [];
|
||||||
commitCurrent(items[0]);
|
commitCurrent(items[0]);
|
||||||
commitQueue(shuffleRef.current ? shuffleTracks(items.slice(1)) : items.slice(1));
|
commitQueue(items.slice(1));
|
||||||
|
commitShuffle(false);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setDuration(items[0].duration);
|
setDuration(items[0].duration);
|
||||||
}, [commitCurrent, commitQueue]);
|
}, [commitCurrent, commitQueue, commitShuffle]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current,
|
current,
|
||||||
@@ -253,6 +291,7 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra
|
|||||||
isShuffled,
|
isShuffled,
|
||||||
repeatMode,
|
repeatMode,
|
||||||
play,
|
play,
|
||||||
|
playFromContext,
|
||||||
playCollection,
|
playCollection,
|
||||||
toggle,
|
toggle,
|
||||||
seek,
|
seek,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { Album, Track } from "../types";
|
import type { Album, Track } from "../types";
|
||||||
import { orderAlbumsByRecentlyAdded, orderTracksByPlayCount, pickRandomTrack } from "./libraryOrdering";
|
import {
|
||||||
|
orderAlbumsByRecentlyAdded,
|
||||||
|
orderAlbumTracks,
|
||||||
|
orderTracksByPlayCount,
|
||||||
|
pickRandomTrack,
|
||||||
|
selectHeavyRotation,
|
||||||
|
} from "./libraryOrdering";
|
||||||
|
|
||||||
const album = (id: string, addedAt?: string): Album => ({
|
const album = (id: string, addedAt?: string): Album => ({
|
||||||
id,
|
id,
|
||||||
@@ -50,6 +56,27 @@ describe("library ordering", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("only returns a heavy rotation after several tracks have repeat listens", () => {
|
||||||
|
expect(selectHeavyRotation([track("one-play", 1), track("repeat-one", 2), track("repeat-two", 4)])).toEqual([]);
|
||||||
|
|
||||||
|
expect(selectHeavyRotation([
|
||||||
|
track("one-play", 1),
|
||||||
|
track("repeat-one", 2),
|
||||||
|
track("repeat-two", 4),
|
||||||
|
track("repeat-three", 3),
|
||||||
|
]).map(({ id }) => id)).toEqual(["repeat-two", "repeat-three", "repeat-one"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders album playback by disc and track number", () => {
|
||||||
|
const tracks = [
|
||||||
|
{ ...track("disc-two"), discNumber: 2, trackNumber: 1 },
|
||||||
|
{ ...track("track-two"), discNumber: 1, trackNumber: 2 },
|
||||||
|
{ ...track("track-one"), discNumber: 1, trackNumber: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(orderAlbumTracks(tracks).map(({ id }) => id)).toEqual(["track-one", "track-two", "disc-two"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("picks a different spotlight track when another track is available", () => {
|
it("picks a different spotlight track when another track is available", () => {
|
||||||
const tracks = [track("current"), track("middle"), track("last")];
|
const tracks = [track("current"), track("middle"), track("last")];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Album, Track } from "../types";
|
import type { Album, Track } from "../types";
|
||||||
|
|
||||||
|
export const minimumHeavyRotationPlays = 2;
|
||||||
|
export const minimumHeavyRotationTracks = 3;
|
||||||
|
|
||||||
export function orderAlbumsByRecentlyAdded(albums: Album[]): Album[] {
|
export function orderAlbumsByRecentlyAdded(albums: Album[]): Album[] {
|
||||||
return albums
|
return albums
|
||||||
.map((album, index) => ({
|
.map((album, index) => ({
|
||||||
@@ -22,6 +25,24 @@ export function orderTracksByPlayCount(tracks: Track[]): Track[] {
|
|||||||
.map(({ track }) => track);
|
.map(({ track }) => track);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectHeavyRotation(tracks: Track[]): Track[] {
|
||||||
|
const repeatedTracks = orderTracksByPlayCount(
|
||||||
|
tracks.filter((track) => (track.playCount ?? 0) >= minimumHeavyRotationPlays),
|
||||||
|
);
|
||||||
|
return repeatedTracks.length >= minimumHeavyRotationTracks ? repeatedTracks : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderAlbumTracks(tracks: Track[]): Track[] {
|
||||||
|
return tracks
|
||||||
|
.map((track, index) => ({ track, index }))
|
||||||
|
.sort((left, right) => (
|
||||||
|
(left.track.discNumber ?? 1) - (right.track.discNumber ?? 1)
|
||||||
|
|| (left.track.trackNumber ?? Number.MAX_SAFE_INTEGER) - (right.track.trackNumber ?? Number.MAX_SAFE_INTEGER)
|
||||||
|
|| left.index - right.index
|
||||||
|
))
|
||||||
|
.map(({ track }) => track);
|
||||||
|
}
|
||||||
|
|
||||||
export function pickRandomTrack(
|
export function pickRandomTrack(
|
||||||
tracks: Track[],
|
tracks: Track[],
|
||||||
currentId?: string,
|
currentId?: string,
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ button:disabled { cursor: wait; opacity: 0.65; }
|
|||||||
.skip-link:focus { transform: translateY(0); }
|
.skip-link:focus { transform: translateY(0); }
|
||||||
|
|
||||||
.app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); height: 100vh; background: var(--canvas); }
|
.app-shell { display: grid; grid-template-columns: 218px minmax(0, 1fr); height: 100vh; background: var(--canvas); }
|
||||||
|
.app-bootstrap { position: relative; display: grid; width: 100vw; height: 100vh; place-items: center; align-content: center; gap: 7px; color: var(--muted); background: var(--canvas); }
|
||||||
|
.app-bootstrap .brand { position: absolute; top: 25px; left: 16px; padding: 0 8px; color: var(--ink); }
|
||||||
|
.app-bootstrap .library-status-icon { margin: 0 0 13px; }
|
||||||
|
.app-bootstrap > strong { color: var(--ink); font-size: 0.92rem; font-weight: 600; }
|
||||||
|
.app-bootstrap > span { color: var(--faint); font-size: 0.7rem; }
|
||||||
|
|
||||||
.sidebar { position: relative; z-index: 30; display: flex; height: calc(100vh - var(--player-height)); flex-direction: column; min-width: 0; padding: 25px 16px 16px; border-right: 1px solid var(--line); background: oklch(0.15 0.009 275); }
|
.sidebar { position: relative; z-index: 30; display: flex; height: calc(100vh - var(--player-height)); flex-direction: column; min-width: 0; padding: 25px 16px 16px; border-right: 1px solid var(--line); background: oklch(0.15 0.009 275); }
|
||||||
.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 33px; font-size: 1.06rem; font-weight: 700; letter-spacing: -0.03em; }
|
.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 33px; font-size: 1.06rem; font-weight: 700; letter-spacing: -0.03em; }
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface Track {
|
|||||||
streamUrl?: string;
|
streamUrl?: string;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
playCount?: number;
|
playCount?: number;
|
||||||
|
discNumber?: number;
|
||||||
|
trackNumber?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user