Randomize the home spotlight, rank Heavy Rotation by completed-listen play counts, and order Recently Added by server creation time.

Hop-State: A_06FNQ2R6AQ3B9NSHVW23CQR
Hop-Proposal: R_06FNQ2QHRBKW41S1ABWG2H0
Hop-Task: T_06FNQ1AJ3NQR3AKN8Z3A1F0
Hop-Attempt: AT_06FNQ1AJ3NAGBVDV111JNTG
This commit is contained in:
2026-07-13 05:54:25 -07:00
committed by Hop
parent 2b4c4743e4
commit f3b1d85936
9 changed files with 208 additions and 30 deletions
+46 -12
View File
@@ -34,6 +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 { fetchAllPages, pollForLibrary } from "./lib/librarySync"; import { fetchAllPages, pollForLibrary } from "./lib/librarySync";
import { import {
chooseMusicFolder, chooseMusicFolder,
@@ -45,7 +46,7 @@ import {
stopManagedServer, stopManagedServer,
type ManagedServerStatus, type ManagedServerStatus,
} from "./lib/managedServer"; } from "./lib/managedServer";
import { buildApiUrl, requestSubsonic, setSubsonicFavorite, streamUrl } from "./lib/subsonic"; import { buildApiUrl, requestSubsonic, setSubsonicFavorite, streamUrl, submitSubsonicScrobble } 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"];
@@ -93,8 +94,8 @@ const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [
{ view: "favorites", label: "Favorites", icon: <Heart size={18} /> }, { view: "favorites", label: "Favorites", icon: <Heart size={18} /> },
]; ];
interface ServerAlbum { id: string; name: string; artist?: string; year?: number; coverArt?: 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 } interface ServerSong { id: string; title: string; artist?: string; album?: string; albumId?: string; duration?: number; coverArt?: string; starred?: string; playCount?: number }
interface LoadedLibrary { albums: Album[]; tracks: Track[] } interface LoadedLibrary { albums: Album[]; tracks: Track[] }
type LibraryPhase = "ready" | "scanning" | "empty"; type LibraryPhase = "ready" | "scanning" | "empty";
@@ -108,6 +109,7 @@ export default function App() {
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);
const [spotlightTrackId, setSpotlightTrackId] = useState(() => pickRandomTrack(demoTracks)?.id ?? "");
const [favorites, setFavorites] = useState(readDemoFavorites); const [favorites, setFavorites] = useState(readDemoFavorites);
const favoritesRef = useRef(favorites); const favoritesRef = useRef(favorites);
const favoritePendingRef = useRef(new Set<string>()); const favoritePendingRef = useRef(new Set<string>());
@@ -129,8 +131,25 @@ export default function App() {
username: localStorage.getItem("resonant.username") ?? "", username: localStorage.getItem("resonant.username") ?? "",
password: "", password: "",
}); });
const player = usePlayer(libraryTracks); const recordCompletedPlay = (track: Track) => {
const incrementPlayCount = () => {
setLibraryTracks((tracks) => tracks.map((item) => item.id === track.id
? { ...item, playCount: (item.playCount ?? 0) + 1 }
: item));
};
if (connectionStatus === "demo") {
incrementPlayCount();
return;
}
if (connectionStatus !== "connected" || !server.url || !server.username || !server.password) return;
void submitSubsonicScrobble(server, track.id).then(incrementPlayCount).catch(() => undefined);
};
const player = usePlayer(libraryTracks, recordCompletedPlay);
const hasLibraryTracks = libraryTracks.length > 0; const hasLibraryTracks = libraryTracks.length > 0;
const spotlightTrack = libraryTracks.find((track) => track.id === spotlightTrackId) ?? libraryTracks[0];
const spotlightIsPlaying = spotlightTrack?.id === player.current.id && player.isPlaying;
const repeatLabel = player.repeatMode === "off" const repeatLabel = player.repeatMode === "off"
? "Turn repeat all on" ? "Turn repeat all on"
: player.repeatMode === "all" : player.repeatMode === "all"
@@ -144,10 +163,17 @@ export default function App() {
return result; return result;
}, [favorites, libraryTracks, query, view]); }, [favorites, libraryTracks, query, view]);
const displayedTracks = useMemo(() => {
const tracks = view === "home" ? orderTracksByPlayCount(visibleTracks) : visibleTracks;
return tracks.slice(0, view === "songs" ? tracks.length : view === "home" && !query ? 6 : 30);
}, [query, view, visibleTracks]);
const recentlyAddedAlbums = useMemo(() => orderAlbumsByRecentlyAdded(libraryAlbums), [libraryAlbums]);
const visibleAlbums = useMemo(() => { const visibleAlbums = useMemo(() => {
const needle = query.trim().toLowerCase(); const needle = query.trim().toLowerCase();
return needle ? libraryAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : libraryAlbums; return needle ? recentlyAddedAlbums.filter((album) => `${album.title} ${album.artist}`.toLowerCase().includes(needle)) : recentlyAddedAlbums;
}, [libraryAlbums, query]); }, [query, recentlyAddedAlbums]);
const artists = useMemo(() => buildArtistSummaries(libraryTracks), [libraryTracks]); const artists = useMemo(() => buildArtistSummaries(libraryTracks), [libraryTracks]);
@@ -259,6 +285,7 @@ export default function App() {
year: album.year ?? 0, year: album.year ?? 0,
coverTone: tones[index % tones.length], coverTone: tones[index % tones.length],
coverUrl: album.coverArt ? buildApiUrl(config, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined, coverUrl: album.coverArt ? buildApiUrl(config, "getCoverArt", { id: album.coverArt, size: 600 }).toString() : undefined,
addedAt: album.created,
})); }));
const tracks = serverSongs.map((song, index): Track => ({ const tracks = serverSongs.map((song, index): Track => ({
id: song.id, id: song.id,
@@ -271,6 +298,7 @@ export default function App() {
coverUrl: song.coverArt ? buildApiUrl(config, "getCoverArt", { id: song.coverArt, size: 300 }).toString() : undefined, coverUrl: song.coverArt ? buildApiUrl(config, "getCoverArt", { id: song.coverArt, size: 300 }).toString() : undefined,
streamUrl: streamUrl(config, song.id), streamUrl: streamUrl(config, song.id),
favorite: Boolean(song.starred), favorite: Boolean(song.starred),
playCount: song.playCount ?? 0,
})); }));
return { albums, tracks }; return { albums, tracks };
}; };
@@ -278,6 +306,7 @@ export default function App() {
const applyServerLibrary = ({ albums, tracks }: LoadedLibrary) => { const applyServerLibrary = ({ albums, tracks }: LoadedLibrary) => {
setLibraryAlbums(albums); setLibraryAlbums(albums);
setLibraryTracks(tracks); setLibraryTracks(tracks);
setSpotlightTrackId((current) => pickRandomTrack(tracks, current)?.id ?? "");
commitFavorites(new Set(tracks.filter((track) => track.favorite).map((track) => track.id))); commitFavorites(new Set(tracks.filter((track) => track.favorite).map((track) => track.id)));
if (tracks.length) player.replaceTracks(tracks); else player.clearTracks(); if (tracks.length) player.replaceTracks(tracks); else player.clearTracks();
}; };
@@ -341,6 +370,7 @@ export default function App() {
setLibraryAlbums([]); setLibraryAlbums([]);
setLibraryTracks([]); setLibraryTracks([]);
setSpotlightTrackId("");
commitFavorites(new Set()); commitFavorites(new Set());
player.clearTracks(); player.clearTracks();
setServer(config); setServer(config);
@@ -383,6 +413,7 @@ export default function App() {
setManagedStatus(status); setManagedStatus(status);
setLibraryAlbums(demoAlbums); setLibraryAlbums(demoAlbums);
setLibraryTracks(demoTracks); setLibraryTracks(demoTracks);
setSpotlightTrackId((current) => pickRandomTrack(demoTracks, current)?.id ?? "");
commitFavorites(readDemoFavorites()); commitFavorites(readDemoFavorites());
player.replaceTracks(demoTracks); player.replaceTracks(demoTracks);
setLibraryPhase("ready"); setLibraryPhase("ready");
@@ -430,6 +461,9 @@ export default function App() {
}, []); }, []);
const goTo = (next: View) => { const goTo = (next: View) => {
if (next === "home") {
setSpotlightTrackId((current) => pickRandomTrack(libraryTracks, current)?.id ?? "");
}
setView(next); setView(next);
setMobileNav(false); setMobileNav(false);
if (next !== "favorites") setQuery(""); if (next !== "favorites") setQuery("");
@@ -507,10 +541,10 @@ export default function App() {
{libraryPhase === "scanning" ? <div className="scan-progress"><span /><span /><span /><small>Scanning the selected folder</small></div> : <button type="button" className="button-secondary" onClick={() => goTo("settings")}><Settings size={16} /> Check library source</button>} {libraryPhase === "scanning" ? <div className="scan-progress"><span /><span /><span /><small>Scanning the selected folder</small></div> : <button type="button" className="button-secondary" onClick={() => goTo("settings")}><Settings size={16} /> Check library source</button>}
</section> </section>
) : <> ) : <>
{hasLibraryTracks && 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>{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-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-art" aria-hidden="true"><Cover album={player.current} 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>
)} )}
@@ -519,13 +553,13 @@ export default function App() {
{visibleArtists.length ? <div className="artist-list" role="list">{visibleArtists.map((artist) => <div role="listitem" key={artist.name.toLocaleLowerCase()}><button className="artist-row" type="button" aria-label={`Play music by ${artist.name}`} onClick={() => player.playCollection(artist.tracks)}><ArtistPortrait artist={artist} /><span className="artist-meta"><strong>{artist.name}</strong><small>{artist.albumCount} {artist.albumCount === 1 ? "album" : "albums"} · {artist.tracks.length} {artist.tracks.length === 1 ? "track" : "tracks"}</small></span><span className="artist-play" aria-hidden="true"><Play size={16} fill="currentColor" /></span></button></div>)}</div> : <div className="empty-state"><Search size={26} /><h3>No artists found</h3><p>Try another artist name.</p></div>} {visibleArtists.length ? <div className="artist-list" role="list">{visibleArtists.map((artist) => <div role="listitem" key={artist.name.toLocaleLowerCase()}><button className="artist-row" type="button" aria-label={`Play music by ${artist.name}`} onClick={() => player.playCollection(artist.tracks)}><ArtistPortrait artist={artist} /><span className="artist-meta"><strong>{artist.name}</strong><small>{artist.albumCount} {artist.albumCount === 1 ? "album" : "albums"} · {artist.tracks.length} {artist.tracks.length === 1 ? "track" : "tracks"}</small></span><span className="artist-play" aria-hidden="true"><Play size={16} fill="currentColor" /></span></button></div>)}</div> : <div className="empty-state"><Search size={26} /><h3>No artists found</h3><p>Try another artist name.</p></div>}
</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" : "Back in rotation"}</span><h2 id="albums-heading">{query ? "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 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>}
</section>} </section>}
<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" : 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>
{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">{visibleTracks.slice(0, view === "songs" ? visibleTracks.length : view === "home" && !query ? 6 : 30).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 === "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>}
<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>
</>} </>}
+14 -14
View File
@@ -1,23 +1,23 @@
import type { Album, Track } from "../types"; import type { Album, Track } from "../types";
export const albums: Album[] = [ export const albums: Album[] = [
{ id: "a1", title: "Afterimage", artist: "Hollow Cities", year: 2026, coverTone: "ember" }, { id: "a1", title: "Afterimage", artist: "Hollow Cities", year: 2026, coverTone: "ember", addedAt: "2026-06-30T18:00:00Z" },
{ id: "a2", title: "Soft Geometry", artist: "Night Archive", year: 2025, coverTone: "moss" }, { id: "a2", title: "Soft Geometry", artist: "Night Archive", year: 2025, coverTone: "moss", addedAt: "2026-03-05T18:00:00Z" },
{ id: "a3", title: "A Quiet Current", artist: "Mara Venn", year: 2024, coverTone: "dusk" }, { id: "a3", title: "A Quiet Current", artist: "Mara Venn", year: 2024, coverTone: "dusk", addedAt: "2025-12-01T18:00:00Z" },
{ id: "a4", title: "Signal Fires", artist: "Orchard Bloom", year: 2026, coverTone: "clay" }, { id: "a4", title: "Signal Fires", artist: "Orchard Bloom", year: 2026, coverTone: "clay", addedAt: "2026-07-10T18:00:00Z" },
{ id: "a5", title: "Low Season", artist: "North Window", year: 2023, coverTone: "indigo" }, { id: "a5", title: "Low Season", artist: "North Window", year: 2023, coverTone: "indigo", addedAt: "2025-08-10T18:00:00Z" },
{ id: "a6", title: "Rooms of Air", artist: "Serein", year: 2025, coverTone: "sand" }, { id: "a6", title: "Rooms of Air", artist: "Serein", year: 2025, coverTone: "sand", addedAt: "2026-05-14T18:00:00Z" },
]; ];
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 }, { id: "t1", title: "Still Life in Motion", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 238, coverTone: "ember", favorite: true, playCount: 12 },
{ id: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss" }, { id: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss", playCount: 27 },
{ id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true }, { id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true, playCount: 18 },
{ id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay" }, { id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay", playCount: 5 },
{ id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo" }, { id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo", playCount: 34 },
{ id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand" }, { id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand", playCount: 2 },
{ id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember" }, { id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember", playCount: 21 },
{ id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss" }, { id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss", playCount: 9 },
]; ];
export const formatTime = (seconds: number) => { export const formatTime = (seconds: number) => {
+21 -1
View File
@@ -25,6 +25,15 @@ class AudioMock extends EventTarget {
} }
} }
let latestAudio: AudioMock;
class CapturedAudioMock extends AudioMock {
constructor() {
super();
latestAudio = this;
}
}
const tracks = ["First", "Second", "Third", "Fourth"].map((title, index): Track => ({ const tracks = ["First", "Second", "Third", "Fourth"].map((title, index): Track => ({
id: String(index + 1), id: String(index + 1),
title, title,
@@ -46,7 +55,7 @@ const longLibrary = Array.from({ length: 18 }, (_, index): Track => ({
})); }));
describe("usePlayer playback modes", () => { describe("usePlayer playback modes", () => {
beforeAll(() => vi.stubGlobal("Audio", AudioMock)); beforeAll(() => vi.stubGlobal("Audio", CapturedAudioMock));
afterAll(() => vi.unstubAllGlobals()); afterAll(() => vi.unstubAllGlobals());
it("toggles queue shuffle and cycles all repeat modes", () => { it("toggles queue shuffle and cycles all repeat modes", () => {
@@ -88,4 +97,15 @@ describe("usePlayer playback modes", () => {
expect(result.current.current.id).toBe("long-18"); expect(result.current.current.id).toBe("long-18");
expect(result.current.queue).toHaveLength(0); expect(result.current.queue).toHaveLength(0);
}); });
it("reports a completed track before advancing the queue", () => {
const onTrackCompleted = vi.fn();
const { result } = renderHook(() => usePlayer(tracks, onTrackCompleted));
act(() => latestAudio.dispatchEvent(new Event("ended")));
expect(onTrackCompleted).toHaveBeenCalledOnce();
expect(onTrackCompleted).toHaveBeenCalledWith(tracks[0]);
expect(result.current.current.id).toBe(tracks[1].id);
});
}); });
+8 -2
View File
@@ -2,8 +2,9 @@ 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";
export function usePlayer(initialTracks: Track[]) { 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 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)); const queueRef = useRef<Track[]>(initialTracks.slice(1));
@@ -19,6 +20,8 @@ export function usePlayer(initialTracks: Track[]) {
const [isShuffled, setIsShuffled] = useState(false); const [isShuffled, setIsShuffled] = useState(false);
const [repeatMode, setRepeatMode] = useState<RepeatMode>("off"); const [repeatMode, setRepeatMode] = useState<RepeatMode>("off");
onTrackCompletedRef.current = onTrackCompleted;
const commitCurrent = useCallback((track: Track) => { const commitCurrent = useCallback((track: Track) => {
currentRef.current = track; currentRef.current = track;
setCurrent(track); setCurrent(track);
@@ -87,7 +90,10 @@ export function usePlayer(initialTracks: Track[]) {
setProgress(audio.currentTime); setProgress(audio.currentTime);
if (Number.isFinite(audio.duration)) setDuration(audio.duration); if (Number.isFinite(audio.duration)) setDuration(audio.duration);
}; };
const ended = () => advance(true); const ended = () => {
onTrackCompletedRef.current?.(currentRef.current);
advance(true);
};
audio.addEventListener("timeupdate", update); audio.addEventListener("timeupdate", update);
audio.addEventListener("loadedmetadata", update); audio.addEventListener("loadedmetadata", update);
audio.addEventListener("ended", ended); audio.addEventListener("ended", ended);
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import type { Album, Track } from "../types";
import { orderAlbumsByRecentlyAdded, orderTracksByPlayCount, pickRandomTrack } from "./libraryOrdering";
const album = (id: string, addedAt?: string): Album => ({
id,
title: id,
artist: "Artist",
year: 2026,
coverTone: "indigo",
addedAt,
});
const track = (id: string, playCount?: number): Track => ({
id,
title: id,
artist: "Artist",
album: "Album",
albumId: "album",
duration: 180,
coverTone: "indigo",
playCount,
});
describe("library ordering", () => {
it("puts the most recently added albums first and keeps missing dates stable", () => {
const albums = [
album("older", "2026-01-02T12:00:00Z"),
album("missing-one"),
album("newest", "2026-07-12T12:00:00Z"),
album("missing-two"),
];
expect(orderAlbumsByRecentlyAdded(albums).map(({ id }) => id)).toEqual([
"newest",
"older",
"missing-one",
"missing-two",
]);
});
it("orders heavy rotation by descending play count and preserves ties", () => {
const tracks = [track("quiet", 2), track("first-tie", 12), track("most-played", 40), track("second-tie", 12)];
expect(orderTracksByPlayCount(tracks).map(({ id }) => id)).toEqual([
"most-played",
"first-tie",
"second-tie",
"quiet",
]);
});
it("picks a different spotlight track when another track is available", () => {
const tracks = [track("current"), track("middle"), track("last")];
expect(pickRandomTrack(tracks, "current", () => 0)?.id).toBe("middle");
expect(pickRandomTrack(tracks, "current", () => 0.999)?.id).toBe("last");
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { Album, Track } from "../types";
export function orderAlbumsByRecentlyAdded(albums: Album[]): Album[] {
return albums
.map((album, index) => ({
album,
index,
addedAt: album.addedAt ? Date.parse(album.addedAt) : Number.NaN,
}))
.sort((left, right) => {
const leftDate = Number.isFinite(left.addedAt) ? left.addedAt : Number.NEGATIVE_INFINITY;
const rightDate = Number.isFinite(right.addedAt) ? right.addedAt : Number.NEGATIVE_INFINITY;
return rightDate - leftDate || left.index - right.index;
})
.map(({ album }) => album);
}
export function orderTracksByPlayCount(tracks: Track[]): Track[] {
return tracks
.map((track, index) => ({ track, index }))
.sort((left, right) => (right.track.playCount ?? 0) - (left.track.playCount ?? 0) || left.index - right.index)
.map(({ track }) => track);
}
export function pickRandomTrack(
tracks: Track[],
currentId?: string,
random: () => number = Math.random,
): Track | undefined {
if (tracks.length === 0) return undefined;
const candidates = tracks.length > 1 ? tracks.filter((track) => track.id !== currentId) : tracks;
const index = Math.floor(Math.min(Math.max(random(), 0), 0.9999999999999999) * candidates.length);
return candidates[index];
}
+20 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { buildApiUrl, setSubsonicFavorite } from "./subsonic"; import { buildApiUrl, setSubsonicFavorite, submitSubsonicScrobble } from "./subsonic";
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
@@ -51,4 +51,23 @@ describe("buildApiUrl", () => {
expect(url.pathname).toBe(`/rest/${endpoint}.view`); expect(url.pathname).toBe(`/rest/${endpoint}.view`);
expect(url.searchParams.get("id")).toBe("track-42"); expect(url.searchParams.get("id")).toBe("track-42");
}); });
it("submits completed playback so the server updates its play count", async () => {
vi.stubGlobal("crypto", { getRandomValues: (array: Uint8Array) => array.fill(4) });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ "subsonic-response": { status: "ok" } }),
});
vi.stubGlobal("fetch", fetchMock);
await submitSubsonicScrobble(
{ url: "https://music.example.test", username: "listener", password: "secret" },
"track-99",
);
const url = fetchMock.mock.calls[0][0] as URL;
expect(url.pathname).toBe("/rest/scrobble.view");
expect(url.searchParams.get("id")).toBe("track-99");
expect(url.searchParams.get("submission")).toBe("true");
});
}); });
+4
View File
@@ -64,6 +64,10 @@ export async function setSubsonicFavorite(config: ServerConfig, trackId: string,
await requestSubsonic(config, favorite ? "star" : "unstar", { id: trackId }); await requestSubsonic(config, favorite ? "star" : "unstar", { id: trackId });
} }
export async function submitSubsonicScrobble(config: ServerConfig, trackId: string): Promise<void> {
await requestSubsonic(config, "scrobble", { id: trackId, submission: true });
}
export function streamUrl(config: ServerConfig, trackId: string): string { export function streamUrl(config: ServerConfig, trackId: string): string {
return buildApiUrl(config, "stream", { id: trackId }).toString(); return buildApiUrl(config, "stream", { id: trackId }).toString();
} }
+2
View File
@@ -7,6 +7,7 @@ export interface Album {
year: number; year: number;
coverTone: CoverTone; coverTone: CoverTone;
coverUrl?: string; coverUrl?: string;
addedAt?: string;
} }
export interface Track { export interface Track {
@@ -20,6 +21,7 @@ export interface Track {
coverUrl?: string; coverUrl?: string;
streamUrl?: string; streamUrl?: string;
favorite?: boolean; favorite?: boolean;
playCount?: number;
} }
export interface ServerConfig { export interface ServerConfig {