From 2b4c4743e4c52f69c3c4bc102a6465b49e508178 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Mon, 13 Jul 2026 04:48:04 -0700 Subject: [PATCH] Persist song favorites through OpenSubsonic star and unstar, save demo favorites locally, and add optimistic rollback and player-bar controls. Hop-State: A_06FNPKJ6RJ17D6321FWJY20 Hop-Proposal: R_06FNPKHE0EJYS4NQAHM3560 Hop-Task: T_06FNPJ4Z3FCWESRBBWVMSY0 Hop-Attempt: AT_06FNPJ4Z3CC4EBG8RK6VXP8 --- src/App.tsx | 106 +++++++++++++++++++++++++++++++++------ src/lib/subsonic.test.ts | 29 +++++++++-- src/lib/subsonic.ts | 4 ++ src/styles.css | 13 +++-- 4 files changed, 130 insertions(+), 22 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 23cb54e..330dc22 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { Album as AlbumIcon, + AlertCircle, AudioLines, Check, ChevronRight, @@ -44,11 +45,28 @@ import { stopManagedServer, type ManagedServerStatus, } from "./lib/managedServer"; -import { buildApiUrl, requestSubsonic, streamUrl } from "./lib/subsonic"; +import { buildApiUrl, requestSubsonic, setSubsonicFavorite, streamUrl } from "./lib/subsonic"; import type { Album, ConnectionStatus, CoverTone, ServerConfig, Track, View } from "./types"; const tones: CoverTone[] = ["ember", "moss", "dusk", "clay", "indigo", "sand"]; const queuePreviewLimit = 100; +const demoFavoritesKey = "resonant.demoFavorites"; + +function readDemoFavorites(): Set { + const fallback = new Set(demoTracks.filter((track) => track.favorite).map((track) => track.id)); + try { + const saved = localStorage.getItem(demoFavoritesKey); + if (saved === null) return fallback; + const ids = JSON.parse(saved); + return Array.isArray(ids) && ids.every((id) => typeof id === "string") ? new Set(ids) : fallback; + } catch { + return fallback; + } +} + +function saveDemoFavorites(favorites: Set) { + localStorage.setItem(demoFavoritesKey, JSON.stringify([...favorites])); +} function Cover({ album, size = "medium" }: { album: Pick; size?: "small" | "medium" | "large" }) { return ( @@ -63,8 +81,8 @@ function ArtistPortrait({ artist }: { artist: ArtistSummary }) { return ; } -function IconButton({ label, children, onClick, active = false, pressed, className = "" }: { label: string; children: React.ReactNode; onClick?: () => void; active?: boolean; pressed?: boolean; className?: string }) { - return ; +function IconButton({ label, children, onClick, active = false, pressed, disabled = false, className = "" }: { label: string; children: React.ReactNode; onClick?: () => void; active?: boolean; pressed?: boolean; disabled?: boolean; className?: string }) { + return ; } const navItems: Array<{ view: View; label: string; icon: React.ReactNode }> = [ @@ -90,7 +108,13 @@ export default function App() { const [query, setQuery] = useState(""); const [libraryAlbums, setLibraryAlbums] = useState(demoAlbums); const [libraryTracks, setLibraryTracks] = useState(demoTracks); - const [favorites, setFavorites] = useState(() => new Set(demoTracks.filter((track) => track.favorite).map((track) => track.id))); + const [favorites, setFavorites] = useState(readDemoFavorites); + const favoritesRef = useRef(favorites); + const favoritePendingRef = useRef(new Set()); + const [favoritePendingIds, setFavoritePendingIds] = useState(() => new Set()); + const [favoriteAnnouncement, setFavoriteAnnouncement] = useState({ key: 0, message: "" }); + const [favoriteError, setFavoriteError] = useState(null); + const favoriteErrorTimer = useRef(null); const [connectionStatus, setConnectionStatus] = useState("demo"); const [connectionMessage, setConnectionMessage] = useState("Listening with the demo library"); const [libraryPhase, setLibraryPhase] = useState("ready"); @@ -132,11 +156,59 @@ export default function App() { return needle ? artists.filter((artist) => artist.name.toLocaleLowerCase().includes(needle)) : artists; }, [artists, query]); - const toggleFavorite = (id: string) => setFavorites((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); else next.add(id); + const commitFavorites = (next: Set) => { + favoritesRef.current = next; + setFavorites(next); + }; + + const setFavoriteValue = (id: string, favorite: boolean) => { + const next = new Set(favoritesRef.current); + if (favorite) next.add(id); else next.delete(id); + commitFavorites(next); + setLibraryTracks((tracks) => tracks.map((track) => track.id === id ? { ...track, favorite } : track)); return next; - }); + }; + + const showFavoriteError = (message: string) => { + setFavoriteError(message); + if (favoriteErrorTimer.current !== null) window.clearTimeout(favoriteErrorTimer.current); + favoriteErrorTimer.current = window.setTimeout(() => { + setFavoriteError(null); + favoriteErrorTimer.current = null; + }, 4000); + }; + + const toggleFavorite = async (track: Track) => { + if (favoritePendingRef.current.has(track.id)) return; + const wasFavorite = favoritesRef.current.has(track.id); + const shouldFavorite = !wasFavorite; + favoritePendingRef.current.add(track.id); + setFavoritePendingIds((current) => new Set(current).add(track.id)); + const nextFavorites = setFavoriteValue(track.id, shouldFavorite); + + try { + if (connectionStatus === "demo") { + saveDemoFavorites(nextFavorites); + } else { + if (!server.url || !server.username || !server.password) throw new Error("The music server is not connected."); + await setSubsonicFavorite(server, track.id, shouldFavorite); + } + setFavoriteAnnouncement((current) => ({ + key: current.key + 1, + message: shouldFavorite ? `${track.title} added to favorites` : `${track.title} removed from favorites`, + })); + } catch { + setFavoriteValue(track.id, wasFavorite); + showFavoriteError("Couldn’t save that favorite. Check your server connection and try again."); + } finally { + favoritePendingRef.current.delete(track.id); + setFavoritePendingIds((current) => { + const next = new Set(current); + next.delete(track.id); + return next; + }); + } + }; const addTrackToQueue = (track: Track) => { player.addToQueue(track); @@ -159,6 +231,7 @@ export default function App() { useEffect(() => () => { queueFeedbackTimers.current.forEach((timer) => window.clearTimeout(timer)); queueFeedbackTimers.current.clear(); + if (favoriteErrorTimer.current !== null) window.clearTimeout(favoriteErrorTimer.current); }, []); const fetchServerLibrary = async (config: ServerConfig): Promise => { @@ -205,7 +278,7 @@ export default function App() { const applyServerLibrary = ({ albums, tracks }: LoadedLibrary) => { setLibraryAlbums(albums); setLibraryTracks(tracks); - setFavorites(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(); }; @@ -268,7 +341,7 @@ export default function App() { setLibraryAlbums([]); setLibraryTracks([]); - setFavorites(new Set()); + commitFavorites(new Set()); player.clearTracks(); setServer(config); setLibraryPhase("scanning"); @@ -310,6 +383,7 @@ export default function App() { setManagedStatus(status); setLibraryAlbums(demoAlbums); setLibraryTracks(demoTracks); + commitFavorites(readDemoFavorites()); player.replaceTracks(demoTracks); setLibraryPhase("ready"); setConnectionStatus("demo"); @@ -444,14 +518,14 @@ export default function App() {
{query ? "Matching your search" : "The people behind your library"}

Artists

{visibleArtists.length} artists
{visibleArtists.length ?
{visibleArtists.map((artist) =>
)}
:

No artists found

Try another artist name.

} : <> - {view !== "songs" &&
-
{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Back in rotation"}

{view === "favorites" ? "Loved records" : query ? "Albums" : "Recently added"}

+ {view !== "songs" && view !== "favorites" &&
+
{query ? "Matching your search" : view === "albums" ? "The full shelf" : "Back in rotation"}

{query ? "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.

}
}
{view === "favorites" ? "Kept close" : view === "songs" ? "Every track" : "A familiar thread"}

{view === "favorites" ? "Favorite tracks" : view === "songs" ? "Songs" : "Heavy rotation"}

{visibleTracks.length} tracks
-
{visibleTracks.slice(0, view === "songs" ? visibleTracks.length : view === "home" && !query ? 6 : 30).map((track, index) => { const justQueued = queuedTrackIds.has(track.id); return
{track.album} toggleFavorite(track.id)}>{formatTime(track.duration)} addTrackToQueue(track)}>{justQueued ? : }
; })}
+ {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 ? : }
; })}
} {queueAnnouncement.message}
} @@ -460,10 +534,14 @@ export default function App() { )} + {favoriteAnnouncement.message} + {hasLibraryTracks && } + {favoriteError &&
{favoriteError}
} + {hasLibraryTracks ?
- +
void toggleFavorite(player.current)}>
{player.repeatMode === "one" ? : }
{formatTime(player.progress)} player.seek(Number(event.target.value))} style={{ "--range-progress": `${(player.progress / Math.max(player.duration, 1)) * 100}%` } as React.CSSProperties} />{formatTime(player.duration)}
setQueueOpen(!queueOpen)}> player.changeVolume(Number(event.target.value))} style={{ "--range-progress": `${player.volume * 100}%` } as React.CSSProperties} />
:
{libraryPhase === "scanning" ? "Scanning your music" : "No tracks available"}{connectionMessage}
} diff --git a/src/lib/subsonic.test.ts b/src/lib/subsonic.test.ts index bfc714e..a6abf47 100644 --- a/src/lib/subsonic.test.ts +++ b/src/lib/subsonic.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; -import { buildApiUrl } from "./subsonic"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildApiUrl, setSubsonicFavorite } from "./subsonic"; + +afterEach(() => vi.unstubAllGlobals()); describe("buildApiUrl", () => { it("builds a token-authenticated OpenSubsonic URL", () => { @@ -26,6 +28,27 @@ describe("buildApiUrl", () => { "/ping.view", ); expect(url.pathname).toBe("/base/rest/ping.view"); - vi.unstubAllGlobals(); + }); + + it.each([ + [true, "star"], + [false, "unstar"], + ])("uses the correct endpoint when favorite is %s", async (favorite, endpoint) => { + vi.stubGlobal("crypto", { getRandomValues: (array: Uint8Array) => array.fill(9) }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ "subsonic-response": { status: "ok" } }), + }); + vi.stubGlobal("fetch", fetchMock); + + await setSubsonicFavorite( + { url: "https://music.example.test", username: "listener", password: "secret" }, + "track-42", + favorite, + ); + + const url = fetchMock.mock.calls[0][0] as URL; + expect(url.pathname).toBe(`/rest/${endpoint}.view`); + expect(url.searchParams.get("id")).toBe("track-42"); }); }); diff --git a/src/lib/subsonic.ts b/src/lib/subsonic.ts index 89f0bf0..854c883 100644 --- a/src/lib/subsonic.ts +++ b/src/lib/subsonic.ts @@ -60,6 +60,10 @@ export async function requestSubsonic( return envelope as T; } +export async function setSubsonicFavorite(config: ServerConfig, trackId: string, favorite: boolean): Promise { + await requestSubsonic(config, favorite ? "star" : "unstar", { id: trackId }); +} + export function streamUrl(config: ServerConfig, trackId: string): string { return buildApiUrl(config, "stream", { id: trackId }).toString(); } diff --git a/src/styles.css b/src/styles.css index 66ee693..cb5facb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -221,13 +221,13 @@ main { min-width: 0; height: calc(100vh - var(--player-height)); overflow-y: aut .privacy-note p { max-width: 64ch; margin: 5px 0 0; font-size: 0.72rem; line-height: 1.55; } .player-bar { position: fixed; z-index: 50; right: 0; bottom: 0; left: 0; display: grid; height: var(--player-height); grid-template-columns: minmax(210px, 1fr) minmax(320px, 1.5fr) minmax(210px, 1fr); align-items: center; gap: 18px; padding: 10px 21px; border-top: 1px solid oklch(0.36 0.018 275 / 0.78); background: oklch(0.16 0.01 275); box-shadow: 0 -16px 45px oklch(0.05 0.016 275 / 0.28); } -.now-playing { display: flex; min-width: 0; align-items: center; gap: 11px; padding: 0; border: 0; background: transparent; text-align: left; } -.now-playing > span { min-width: 0; } +.now-playing { display: flex; min-width: 0; align-items: center; gap: 5px; } +.now-playing-main { display: flex; min-width: 0; flex: 1; align-items: center; gap: 11px; padding: 0; border: 0; background: transparent; text-align: left; } +.now-playing-main > span { min-width: 0; } .now-playing strong, .now-playing small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .now-playing strong { color: var(--ink); font-size: 0.75rem; } .now-playing small { margin-top: 3px; color: var(--faint); font-size: 0.65rem; } -.now-playing > svg { flex: 0 0 auto; margin-left: 6px; color: var(--muted); } -.now-playing > svg.filled-heart { color: var(--accent-bright); } +.now-playing-favorite { margin-left: 3px; } .transport { display: grid; justify-items: center; gap: 7px; } .transport-buttons { display: flex; align-items: center; gap: 8px; } .play-button { display: grid; width: 37px; height: 37px; margin: 0 3px; place-items: center; padding: 0; border: 0; border-radius: 50%; color: oklch(0.17 0.012 275); background: oklch(0.93 0.008 275); transition: transform 150ms ease, background 150ms ease; } @@ -262,12 +262,16 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib .queue-item strong { font-size: 0.72rem; } .queue-item small { margin-top: 3px; color: var(--faint); font-size: 0.63rem; } .queue-remainder { margin: 0; padding: 15px 0; color: var(--faint); font-size: 0.68rem; text-align: center; } +.action-toast { position: fixed; z-index: 60; right: 20px; bottom: calc(var(--player-height) + 16px); display: flex; max-width: min(390px, calc(100vw - 32px)); align-items: center; gap: 9px; padding: 11px 13px; border: 1px solid var(--line); border-radius: 10px; color: var(--ink); background: var(--surface-raised); box-shadow: 0 14px 38px oklch(0.05 0.016 275 / 0.42); font-size: 0.72rem; animation: toast-in 180ms cubic-bezier(.22, 1, .36, 1); } +.action-toast--error { border-color: oklch(0.53 0.09 24 / 0.7); color: oklch(0.84 0.07 25); } +.action-toast svg { flex: 0 0 auto; } .nav-scrim { display: none; } @keyframes pulse { 50% { opacity: 0.45; transform: scale(0.82); } } @keyframes scan-turn { to { transform: rotate(360deg); } } @keyframes scan-pulse { 50% { opacity: 0.3; transform: translateY(-2px); } } @keyframes queue-confirm { from { opacity: 0.45; transform: scale(0.62); } to { opacity: 1; transform: scale(1); } } +@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } @media (max-width: 1080px) { .album-grid { grid-template-columns: repeat(4, minmax(120px, 1fr)); } @@ -329,7 +333,6 @@ input[type="range"]:hover::-webkit-slider-thumb, input[type="range"]:focus-visib .form-actions button { width: 100%; } .player-bar { grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 8px 10px; } .now-playing .cover { width: 46px; min-width: 46px; } - .now-playing > svg { display: none; } .transport-buttons { gap: 0; } .transport-buttons .icon-button:nth-child(2), .transport-buttons .icon-button:nth-child(4) { display: none; } .player-tools { right: 63px; }