From c858762a4a7cfbe2a213b8d79f6b3d7c4996304d Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Mon, 13 Jul 2026 06:10:05 -0700 Subject: [PATCH] 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 --- src/App.test.tsx | 86 ++++++++++++++++++++++++++++ src/App.tsx | 79 +++++++++++++++++++++----- src/data/demo.ts | 16 +++--- src/hooks/usePlayer.test.ts | 35 ++++++++++++ src/hooks/usePlayer.ts | 99 +++++++++++++++++++++++---------- src/lib/libraryOrdering.test.ts | 29 +++++++++- src/lib/libraryOrdering.ts | 21 +++++++ src/styles.css | 5 ++ src/types.ts | 2 + 9 files changed, 320 insertions(+), 52 deletions(-) create mode 100644 src/App.test.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..e5b4020 --- /dev/null +++ b/src/App.test.tsx @@ -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() { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + + 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>(); + managedServer.getManagedServerStatus.mockReturnValue(status.promise); + managedServer.getSavedCredentials.mockReturnValue(credentials.promise); + + render(); + + 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(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index e419f5b..56ed898 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,7 @@ import { import { albums as demoAlbums, formatTime, tracks as demoTracks } from "./data/demo"; import { usePlayer } from "./hooks/usePlayer"; 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 { 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 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[] } type LibraryPhase = "ready" | "scanning" | "empty"; @@ -117,6 +117,7 @@ export default function App() { const [favoriteAnnouncement, setFavoriteAnnouncement] = useState({ key: 0, message: "" }); const [favoriteError, setFavoriteError] = useState(null); const favoriteErrorTimer = useRef(null); + const [isBootstrapping, setIsBootstrapping] = useState(true); const [connectionStatus, setConnectionStatus] = useState("demo"); const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library"); const [libraryPhase, setLibraryPhase] = useState("ready"); @@ -163,10 +164,15 @@ export default function App() { return result; }, [favorites, libraryTracks, query, view]); + const heavyRotationTracks = useMemo(() => selectHeavyRotation(libraryTracks), [libraryTracks]); + const hasHeavyRotation = heavyRotationTracks.length > 0; + 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); - }, [query, view, visibleTracks]); + }, [heavyRotationTracks, query, view, visibleTracks]); 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; }, [query, recentlyAddedAlbums]); + const tracksByAlbum = useMemo(() => { + const grouped = new Map(); + 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 visibleArtists = useMemo(() => { @@ -299,6 +315,8 @@ export default function App() { streamUrl: streamUrl(config, song.id), favorite: Boolean(song.starred), playCount: song.playCount ?? 0, + discNumber: song.discNumber, + trackNumber: song.track, })); return { albums, tracks }; }; @@ -396,6 +414,11 @@ export default function App() { setConnectionMessage("Your local library is ready"); } catch (error) { setManagedStatus(await getManagedServerStatus().catch(() => null)); + setLibraryAlbums([]); + setLibraryTracks([]); + setSpotlightTrackId(""); + commitFavorites(new Set()); + player.clearTracks(); setLibraryPhase("empty"); setConnectionStatus("error"); setConnectionMessage(error instanceof Error ? error.message : "Your local library could not start."); @@ -428,6 +451,12 @@ export default function App() { useEffect(() => { let active = true; 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 { const [status, credentials] = await Promise.all([ getManagedServerStatus(), @@ -442,16 +471,29 @@ export default function App() { setLocalSetup({ musicFolder, password: localPassword }); setServer((current) => ({ ...current, password: remotePassword })); - const savedMode = localStorage.getItem("resonant.sourceMode"); - if (savedMode === "local" && musicFolder && localPassword) { + if (savedMode === "local" && musicFolder) { + restoringSavedLibrary = true; + if (!localPassword) throw new Error("Your saved library password is unavailable. Open Library source to reconnect it."); 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}`); } } catch (error) { if (!active) return; + if (restoringSavedLibrary) { + setLibraryAlbums([]); + setLibraryTracks([]); + setSpotlightTrackId(""); + commitFavorites(new Set()); + player.clearTracks(); + setLibraryPhase("empty"); + } setConnectionStatus("error"); setConnectionMessage(error instanceof Error ? error.message : "Your saved library could not be restored."); + } finally { + if (active) setIsBootstrapping(false); } }; void restoreLibrary(); @@ -469,6 +511,17 @@ export default function App() { if (next !== "favorites") setQuery(""); }; + if (isBootstrapping) { + return ( +
+
Resonant
+
+ Opening your library + Restoring your saved connection +
+ ); + } + return (
Skip to library @@ -543,7 +596,7 @@ export default function App() { ) : <> {hasLibraryTracks && spotlightTrack && view === "home" && !query && (
-
Picked from your library

Let it find you again.

{spotlightTrack.title} by {spotlightTrack.artist} surfaced from your library. Start here, then let the queue wander.

+
Picked from your library

Let it find you again.

{spotlightTrack.title} by {spotlightTrack.artist} surfaced from your library. Start here, then let the queue wander.

)} @@ -554,14 +607,14 @@ export default function App() { : <> {view !== "songs" && view !== "favorites" &&
{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Newest in your library"}

{query || view === "albums" ? "Albums" : "Recently added"}

- {visibleAlbums.length ?
{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const firstTrack = libraryTracks.find((track) => track.albumId === album.id); return

{album.title}

{album.artist} · {album.year || "Unknown year"}

; })}
:

No albums found

Try a title, artist, or track name.

} + {visibleAlbums.length ?
{visibleAlbums.slice(0, view === "albums" || query ? visibleAlbums.length : 6).map((album) => { const albumTracks = tracksByAlbum.get(album.id) ?? []; return

{album.title}

{album.artist} · {album.year || "Unknown year"}

; })}
:

No albums found

Try a title, artist, or track name.

}
} -
-
{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 ? : }
; })}
} + {view !== "albums" &&
+
{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 ? hasHeavyRotation ? "By play count" : "Still learning" : `${visibleTracks.length} tracks`}
+ {view === "home" && !query && !hasHeavyRotation ?

Heavy rotation is still taking shape

Finish a few songs more than once and your most-played tracks will appear here.

: 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 3e4d9c2..1fa0f02 100644 --- a/src/data/demo.ts +++ b/src/data/demo.ts @@ -10,14 +10,14 @@ export const albums: Album[] = [ ]; 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: "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 }, + { 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", discNumber: 1, trackNumber: 1 }, + { 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", discNumber: 1, trackNumber: 1 }, + { 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", discNumber: 1, trackNumber: 1 }, + { 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", discNumber: 1, trackNumber: 2 }, ]; export const formatTime = (seconds: number) => { diff --git a/src/hooks/usePlayer.test.ts b/src/hooks/usePlayer.test.ts index 00a229c..67b3535 100644 --- a/src/hooks/usePlayer.test.ts +++ b/src/hooks/usePlayer.test.ts @@ -80,6 +80,41 @@ describe("usePlayer playback modes", () => { 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", () => { const { result } = renderHook(() => usePlayer(tracks)); diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index 7fb6904..b6fdfac 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -2,12 +2,18 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { nextRepeatMode, shuffleTracks, type RepeatMode } from "../lib/playback"; 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) { const audioRef = useRef(null); const onTrackCompletedRef = useRef(onTrackCompleted); - const tracksRef = useRef(initialTracks); + const contextRef = useRef(initialTracks); const currentRef = useRef(initialTracks[0]); const queueRef = useRef(initialTracks.slice(1)); + const unshuffledQueueRef = useRef(initialTracks.slice(1)); const historyRef = useRef([]); const shuffleRef = useRef(false); const repeatRef = useRef("off"); @@ -32,6 +38,11 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra setQueue(items); }, []); + const commitShuffle = useCallback((enabled: boolean) => { + shuffleRef.current = enabled; + setIsShuffled(enabled); + }, []); + const restartCurrent = useCallback((resume: boolean) => { const audio = audioRef.current; if (!audio) return; @@ -53,13 +64,15 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra if (nextTrack) { historyRef.current = [...historyRef.current, currentTrack]; + unshuffledQueueRef.current = removeFirstTrack(unshuffledQueueRef.current, nextTrack.id); commitQueue(queueRef.current.slice(1)); commitCurrent(nextTrack); return; } 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) { cycle = shuffleTracks(cycle); if (cycle.length > 1 && cycle[0].id === currentTrack.id) { @@ -75,6 +88,15 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra commitCurrent(cycle[0]); 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; } @@ -121,28 +143,46 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra 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]; - 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); setIsPlaying(true); }, [commitCurrent, commitQueue]); - const playCollection = useCallback((items: Track[]) => { - const firstTrack = items[0]; - if (!firstTrack) return; + const playFromContext = useCallback((track: Track, items: Track[]) => { + const trackIndex = items.findIndex((item) => item.id === track.id); + const context = trackIndex >= 0 ? [...items] : [track]; + const upcoming = trackIndex >= 0 ? items.slice(trackIndex + 1) : []; historyRef.current = []; - const upcoming = shuffleRef.current ? shuffleTracks(items.slice(1)) : items.slice(1); + contextRef.current = context; + unshuffledQueueRef.current = upcoming; + commitShuffle(false); commitQueue(upcoming); - if (firstTrack.id === currentRef.current.id) { + if (track.id === currentRef.current.id) { void audioRef.current?.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false)); return; } - commitCurrent(firstTrack); + commitCurrent(track); setIsPlaying(true); - }, [commitCurrent, commitQueue]); + }, [commitCurrent, commitQueue, commitShuffle]); + + const playCollection = useCallback((items: Track[]) => { + if (items[0]) playFromContext(items[0], items); + }, [playFromContext]); const toggle = useCallback(() => { const audio = audioRef.current; @@ -177,35 +217,26 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra const previousTrack = historyRef.current.at(-1); if (!previousTrack) return; historyRef.current = historyRef.current.slice(0, -1); + unshuffledQueueRef.current = [currentRef.current, ...unshuffledQueueRef.current]; commitQueue([currentRef.current, ...queueRef.current]); commitCurrent(previousTrack); }, [commitCurrent, commitQueue]); const toggleShuffle = useCallback(() => { const nextShuffled = !shuffleRef.current; - shuffleRef.current = nextShuffled; - setIsShuffled(nextShuffled); + commitShuffle(nextShuffled); - let upcoming = queueRef.current; if (nextShuffled) { - if (upcoming.length === 0) { - const playedIds = new Set([...historyRef.current, currentRef.current].map((track) => track.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)) { + const shuffled = shuffleTracks(unshuffledQueueRef.current); + if (shuffled.length > 1 && shuffled.every((track, index) => track.id === unshuffledQueueRef.current[index].id)) { shuffled.push(shuffled.shift()!); } commitQueue(shuffled); return; } - const libraryOrder = new Map(tracksRef.current.map((track, index) => [track.id, index])); - commitQueue(upcoming - .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]); + commitQueue(unshuffledQueueRef.current); + }, [commitQueue, commitShuffle]); const cycleRepeat = useCallback(() => { const nextMode = nextRepeatMode(repeatRef.current); @@ -214,34 +245,41 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra }, []); const addToQueue = useCallback((track: Track) => { + contextRef.current = [...contextRef.current, track]; + unshuffledQueueRef.current = [...unshuffledQueueRef.current, track]; commitQueue([...queueRef.current, track]); }, [commitQueue]); const removeFromQueue = useCallback((id: string) => { + unshuffledQueueRef.current = unshuffledQueueRef.current.filter((track) => track.id !== id); commitQueue(queueRef.current.filter((track) => track.id !== id)); }, [commitQueue]); const clearTracks = useCallback(() => { audioRef.current?.pause(); - tracksRef.current = []; + contextRef.current = []; + unshuffledQueueRef.current = []; historyRef.current = []; commitQueue([]); + commitShuffle(false); setIsPlaying(false); setProgress(0); setDuration(0); - }, [commitQueue]); + }, [commitQueue, commitShuffle]); const replaceTracks = useCallback((items: Track[]) => { if (items.length === 0) return; audioRef.current?.pause(); - tracksRef.current = items; + contextRef.current = items; + unshuffledQueueRef.current = items.slice(1); historyRef.current = []; commitCurrent(items[0]); - commitQueue(shuffleRef.current ? shuffleTracks(items.slice(1)) : items.slice(1)); + commitQueue(items.slice(1)); + commitShuffle(false); setIsPlaying(false); setProgress(0); setDuration(items[0].duration); - }, [commitCurrent, commitQueue]); + }, [commitCurrent, commitQueue, commitShuffle]); return { current, @@ -253,6 +291,7 @@ export function usePlayer(initialTracks: Track[], onTrackCompleted?: (track: Tra isShuffled, repeatMode, play, + playFromContext, playCollection, toggle, seek, diff --git a/src/lib/libraryOrdering.test.ts b/src/lib/libraryOrdering.test.ts index 84dc9ba..105e8ad 100644 --- a/src/lib/libraryOrdering.test.ts +++ b/src/lib/libraryOrdering.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; 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 => ({ 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", () => { const tracks = [track("current"), track("middle"), track("last")]; diff --git a/src/lib/libraryOrdering.ts b/src/lib/libraryOrdering.ts index 6fb4772..06eba8e 100644 --- a/src/lib/libraryOrdering.ts +++ b/src/lib/libraryOrdering.ts @@ -1,5 +1,8 @@ import type { Album, Track } from "../types"; +export const minimumHeavyRotationPlays = 2; +export const minimumHeavyRotationTracks = 3; + export function orderAlbumsByRecentlyAdded(albums: Album[]): Album[] { return albums .map((album, index) => ({ @@ -22,6 +25,24 @@ export function orderTracksByPlayCount(tracks: 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( tracks: Track[], currentId?: string, diff --git a/src/styles.css b/src/styles.css index cb5facb..f86727b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -36,6 +36,11 @@ button:disabled { cursor: wait; opacity: 0.65; } .skip-link:focus { transform: translateY(0); } .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); } .brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 33px; font-size: 1.06rem; font-weight: 700; letter-spacing: -0.03em; } diff --git a/src/types.ts b/src/types.ts index fe5836d..3b94000 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,8 @@ export interface Track { streamUrl?: string; favorite?: boolean; playCount?: number; + discNumber?: number; + trackNumber?: number; } export interface ServerConfig {