- {view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : "A familiar thread"}
{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}
{visibleTracks.length} tracks
- {view === "favorites" && visibleTracks.length === 0 ? No favorite tracks yet
Use the heart beside a song to keep it here.
: {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
{track.album} void toggleFavorite(track)}>{formatTime(track.duration)} addTrackToQueue(track)}>{justQueued ? : } ; })}
}
+ {view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : query ? "Matching your search" : "Most played by listen count"}
{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}
{view === "home" && !query ? "By play count" : `${visibleTracks.length} tracks`}
+ {view === "favorites" && visibleTracks.length === 0 ? No favorite tracks yet
Use the heart beside a song to keep it here.
: {displayedTracks.map((track, index) => { const justQueued = queuedTrackIds.has(track.id); const favoritePending = favoritePendingIds.has(track.id); return
{track.album} void toggleFavorite(track)}>{formatTime(track.duration)} addTrackToQueue(track)}>{justQueued ? : } ; })}
}
{queueAnnouncement.message}
>}
diff --git a/src/data/demo.ts b/src/data/demo.ts
index 20b3aba..3e4d9c2 100644
--- a/src/data/demo.ts
+++ b/src/data/demo.ts
@@ -1,23 +1,23 @@
import type { Album, Track } from "../types";
export const albums: Album[] = [
- { id: "a1", title: "Afterimage", artist: "Hollow Cities", year: 2026, coverTone: "ember" },
- { id: "a2", title: "Soft Geometry", artist: "Night Archive", year: 2025, coverTone: "moss" },
- { id: "a3", title: "A Quiet Current", artist: "Mara Venn", year: 2024, coverTone: "dusk" },
- { id: "a4", title: "Signal Fires", artist: "Orchard Bloom", year: 2026, coverTone: "clay" },
- { id: "a5", title: "Low Season", artist: "North Window", year: 2023, coverTone: "indigo" },
- { id: "a6", title: "Rooms of Air", artist: "Serein", year: 2025, coverTone: "sand" },
+ { 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", addedAt: "2026-03-05T18:00:00Z" },
+ { 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", addedAt: "2026-07-10T18:00:00Z" },
+ { 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", addedAt: "2026-05-14T18:00:00Z" },
];
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: "t2", title: "Violet Hours", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 284, coverTone: "moss" },
- { id: "t3", title: "Understory", artist: "Mara Venn", album: "A Quiet Current", albumId: "a3", duration: 217, coverTone: "dusk", favorite: true },
- { id: "t4", title: "Antenna Weather", artist: "Orchard Bloom", album: "Signal Fires", albumId: "a4", duration: 195, coverTone: "clay" },
- { id: "t5", title: "The Long Interior", artist: "North Window", album: "Low Season", albumId: "a5", duration: 309, coverTone: "indigo" },
- { id: "t6", title: "Nearer Than Before", artist: "Serein", album: "Rooms of Air", albumId: "a6", duration: 246, coverTone: "sand" },
- { id: "t7", title: "Everything Returns", artist: "Hollow Cities", album: "Afterimage", albumId: "a1", duration: 261, coverTone: "ember" },
- { id: "t8", title: "Unfolding", artist: "Night Archive", album: "Soft Geometry", albumId: "a2", duration: 228, coverTone: "moss" },
+ { 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", playCount: 27 },
+ { 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", playCount: 5 },
+ { 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", playCount: 2 },
+ { 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", playCount: 9 },
];
export const formatTime = (seconds: number) => {
diff --git a/src/hooks/usePlayer.test.ts b/src/hooks/usePlayer.test.ts
index f380ae8..00a229c 100644
--- a/src/hooks/usePlayer.test.ts
+++ b/src/hooks/usePlayer.test.ts
@@ -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 => ({
id: String(index + 1),
title,
@@ -46,7 +55,7 @@ const longLibrary = Array.from({ length: 18 }, (_, index): Track => ({
}));
describe("usePlayer playback modes", () => {
- beforeAll(() => vi.stubGlobal("Audio", AudioMock));
+ beforeAll(() => vi.stubGlobal("Audio", CapturedAudioMock));
afterAll(() => vi.unstubAllGlobals());
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.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);
+ });
});
diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts
index 694171c..7fb6904 100644
--- a/src/hooks/usePlayer.ts
+++ b/src/hooks/usePlayer.ts
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { nextRepeatMode, shuffleTracks, type RepeatMode } from "../lib/playback";
import type { Track } from "../types";
-export function usePlayer(initialTracks: Track[]) {
+export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Track) => void) {
const audioRef = useRef