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:
+46
-12
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user