diff --git a/CLAUDE.md b/CLAUDE.md index cf0442b0..2ab87f59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ The PR checklist is `typecheck` / `lint` / `cargo fmt --check` / `cargo check`. React 19 + TypeScript. Entry: `src/main.tsx` → `src/App.tsx`. - **Contexts** (provider tree in `App.tsx`): `ThemeContext`, `PlayerContext`, `LibraryContext`, `PlaylistContext`, `ProfileContext`. `PageScrollContext` mounts lower (in `AppLayout`) and exposes the main scrollable area to virtualized tables — one page-driven scrollbar. -- **Hooks** wrap each context: `useTheme`, `usePlayer`, `useLibrary`, `usePlaylist`, `useProfile`, `usePageScroll`. +- **Hooks** wrap each context: `useTheme`, `usePlayer`, `useLibrary`, `usePlaylist`, `useProfile`, `usePageScroll`. The playback position is the exception: it ticks at 4 Hz, so it lives outside `PlayerContext` in [`usePlayerPosition`](src/hooks/usePlayerPosition.ts) ([why](docs/architecture/invariants.md#events)). - **Tauri wrappers** (`src/lib/tauri/`): one typed `invoke()` per backend command. - **Views**: `HomeView`, `LibraryView`, `PlaylistView`, `AlbumDetailView`, `ArtistDetailView`, `LikedView`, `HistoryView`, `StatisticsView`, `WrappedView`, `SettingsView`, … - **Layout**: Apple-Music-style sidebar, TopBar with search, PlayerBar at the bottom, right-edge panels (`NowPlayingPanel` / `QueuePanel` / `LyricsPanel`) mutex'd via `PlayerContext`. A second `WebviewWindow` (label `mini`, `?mini=1`) ships the always-on-top mini-player — [`docs/features/ui.md`](docs/features/ui.md#mini-player) — and a third (label `lyrics`, `?lyrics=1`, created by the backend) the transparent desktop lyrics overlay — [`#desktop-lyrics`](docs/features/ui.md#desktop-lyrics). diff --git a/docs/architecture/invariants.md b/docs/architecture/invariants.md index 92e7832b..d9b64b7d 100644 --- a/docs/architecture/invariants.md +++ b/docs/architecture/invariants.md @@ -53,6 +53,8 @@ The backend emits Tauri events; the frontend listens via `listen()` from `@tauri `player:state` · `player:position` · `player:track-changed` · `player:queue-changed` · `player:options-changed` · `player:volume-changed` · `player:error` · `player:ab-loop` · `player:spectrum` · `track:updated` · `track:liked-changed` · `artist:updated` · `profile-setting:changed` · `library:rescanned` · `scan:progress` · `lyrics:updated` · … +**The playback position is not context state.** `player:position` ticks four times a second; held in `PlayerContext`, each tick re-rendered every `usePlayer()` consumer (38 of them, the library view included) to redraw the handful that show it. It lives in a small external store instead, [`usePlayerPosition`](../../src/hooks/usePlayerPosition.ts): a component that renders the position calls `usePlayerPosition()`, and code that only needs it in a handler calls `getPlayerPosition()` and does not re-render at all. `PlayerProvider` is the store's only writer. Putting `positionMs` back in the context value brings back the 4 Hz re-render of the whole tree. + **Shared state needs an event, because there is more than one window.** The mini-player is a second webview with its own provider tree, so anything a user can change from both places has to be broadcast or the two copies drift — the engine stays right, the two UIs disagree, and the user "fixes" the one that looks wrong and breaks the one that was. `player:options-changed` (repeat + shuffle), `player:volume-changed` and `track:liked-changed` all exist for that reason, and are emitted by the in-app commands too, not only by external surfaces like MPD (#523). The window that made the change has already updated optimistically, so it receives an echo of what it holds — a no-op. With one exception: volume is pushed through a 60 ms debounce, so a returning value can be one the user has already dragged past. That window therefore ignores `player:volume-changed` for 500 ms after its own last change. Discrete toggles need no such guard. diff --git a/src/components/common/LyricsEditorModal.tsx b/src/components/common/LyricsEditorModal.tsx index 80e8ebbb..97edf354 100644 --- a/src/components/common/LyricsEditorModal.tsx +++ b/src/components/common/LyricsEditorModal.tsx @@ -17,6 +17,7 @@ import { } from "lucide-react"; import { save as showSaveDialog } from "@tauri-apps/plugin-dialog"; import { usePlayer } from "../../hooks/usePlayer"; +import { usePlayerPosition } from "../../hooks/usePlayerPosition"; import { useModalA11y } from "../../hooks/useModalA11y"; import { AnimatedModalContent, AnimatedModalShell } from "./AnimatedModalShell"; import { @@ -93,7 +94,8 @@ export function LyricsEditorModal({ trackFilePath, }: LyricsEditorModalProps) { const { t } = useTranslation(); - const { isPlaying, togglePlayback, seek, positionMs } = usePlayer(); + const { isPlaying, togglePlayback, seek } = usePlayer(); + const positionMs = usePlayerPosition(); const dialogRef = useModalA11y(isOpen, onClose); const [mode, setMode] = useState("plain"); diff --git a/src/components/player/AbLoopButton.tsx b/src/components/player/AbLoopButton.tsx index 0d5eff60..7c6ed0f7 100644 --- a/src/components/player/AbLoopButton.tsx +++ b/src/components/player/AbLoopButton.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Repeat2 } from "lucide-react"; import { listen } from "@tauri-apps/api/event"; import { usePlayer } from "../../hooks/usePlayer"; +import { getPlayerPosition } from "../../hooks/usePlayerPosition"; import { playerClearAbLoop, playerGetAbLoop, @@ -23,7 +24,7 @@ import { */ export function AbLoopButton() { const { t } = useTranslation(); - const { positionMs, currentTrack } = usePlayer(); + const { currentTrack } = usePlayer(); const [snap, setSnap] = useState({ a_ms: null, b_ms: null }); useEffect(() => { @@ -55,7 +56,7 @@ export function AbLoopButton() { } // Use position - 1 ms to avoid the loop firing on the same // sample we just captured B at (would cause an immediate seek). - const ms = Math.max(0, Math.floor(positionMs)); + const ms = Math.max(0, Math.floor(getPlayerPosition())); if (!hasA) { const next = await playerSetAbLoop(ms, null); setSnap(next); diff --git a/src/components/player/HeldNote.tsx b/src/components/player/HeldNote.tsx index f5fb973a..cf12d936 100644 --- a/src/components/player/HeldNote.tsx +++ b/src/components/player/HeldNote.tsx @@ -1,6 +1,7 @@ import { Fragment, useState, type CSSProperties } from "react"; import { usePlayer } from "../../hooks/usePlayer"; +import { usePlayerPosition } from "../../hooks/usePlayerPosition"; import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; import { heldNoteLetters, isLetterSegment } from "../../lib/heldNote"; import type { LyricsWord } from "../../lib/tauri/lyrics"; @@ -46,7 +47,8 @@ export function HeldNoteText({ word: LyricsWord; glow?: boolean; }) { - const { positionMs, isPlaying, playbackSpeed } = usePlayer(); + const { isPlaying, playbackSpeed } = usePlayer(); + const positionMs = usePlayerPosition(); const reduceMotion = usePrefersReducedMotion(); // Fixed when the word comes on screen: re-reading the position every // render would shift the running animations by a quarter second at a diff --git a/src/components/player/ProgressBar.tsx b/src/components/player/ProgressBar.tsx index a2ed97f0..829697be 100644 --- a/src/components/player/ProgressBar.tsx +++ b/src/components/player/ProgressBar.tsx @@ -9,6 +9,7 @@ import { import { listen } from "@tauri-apps/api/event"; import { useTranslation } from "react-i18next"; import { usePlayer } from "../../hooks/usePlayer"; +import { usePlayerPosition } from "../../hooks/usePlayerPosition"; import { isRadioTrack } from "../../lib/playerSources"; import { formatDuration } from "../../lib/tauri/track"; import { playerGetAbLoop, type AbLoopSnapshot } from "../../lib/tauri/player"; @@ -21,8 +22,8 @@ import { playerGetAbLoop, type AbLoopSnapshot } from "../../lib/tauri/player"; */ export function ProgressBar() { const { t } = useTranslation(); - const { positionMs, durationMs, seek, setSeeking, currentTrack } = - usePlayer(); + const { durationMs, seek, setSeeking, currentTrack } = usePlayer(); + const positionMs = usePlayerPosition(); const [dragMs, setDragMs] = useState(null); const trackRef = useRef(null); diff --git a/src/components/views/MiniPlayer.tsx b/src/components/views/MiniPlayer.tsx index c331bdf5..cc7c0111 100644 --- a/src/components/views/MiniPlayer.tsx +++ b/src/components/views/MiniPlayer.tsx @@ -33,6 +33,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { Window as TauriWindow } from "@tauri-apps/api/window"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { usePlayer } from "../../hooks/usePlayer"; +import { usePlayerPosition } from "../../hooks/usePlayerPosition"; import { useLikedTracks } from "../../hooks/useLikedTracks"; import { useTrackLyrics } from "../../hooks/useTrackLyrics"; import { useKaraokeWordFill } from "../../hooks/useKaraokeWordFill"; @@ -97,7 +98,6 @@ export function MiniPlayer() { togglePlayback, next, previous, - positionMs, durationMs, repeatMode, cycleRepeatMode, @@ -110,6 +110,7 @@ export function MiniPlayer() { setVolume, toggleMute, } = usePlayer(); + const positionMs = usePlayerPosition(); // Live radio has no seekable timeline — the seek bar + timestamps are // hidden (matching the PlayerBar / immersive ProgressBar). const isRadio = isRadioTrack(currentTrack); diff --git a/src/contexts/PlayerContext.tsx b/src/contexts/PlayerContext.tsx index d07a8a7e..6ba00d6b 100644 --- a/src/contexts/PlayerContext.tsx +++ b/src/contexts/PlayerContext.tsx @@ -48,6 +48,7 @@ import { import type { PluginFavorite } from "../lib/tauri/plugins"; import { enrichArtistDeezer } from "../lib/tauri/detail"; import { remoteArtwork } from "../lib/tauri/remoteServer"; +import { setPlayerPosition } from "../hooks/usePlayerPosition"; import { isRadioTrack, isRemoteTrack, @@ -168,7 +169,6 @@ export function PlayerProvider({ children }: { children: ReactNode }) { // track plays or playback goes idle. const [currentRadioStation, setCurrentRadioStation] = useState(null); - const [positionMs, setPositionMs] = useState(0); const [durationMs, setDurationMs] = useState(0); // Volume: local + debounced backend push @@ -373,7 +373,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { setPlaybackState("idle"); setCurrentTrack(null); setCurrentRadioStation(null); - setPositionMs(0); + setPlayerPosition(0); setDurationMs(0); // Close the immersive view on profile switch — otherwise `immersiveOpen` // would linger true through the `currentTrack` null window and reopen @@ -386,7 +386,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const snap = await playerGetState(); if (cancelled) return; setPlaybackState(snap.state); - setPositionMs(snap.position_ms); + setPlayerPosition(snap.position_ms); // Volume arrives as 0..1 from the snapshot; UI uses 0..100. setVolumeState(Math.round(snap.volume * 100)); previousVolumeRef.current = Math.round(snap.volume * 100); @@ -459,7 +459,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { unlisten.push( await listen("player:position", (e) => { if (!isSeekingRef.current) { - setPositionMs(e.payload.ms); + setPlayerPosition(e.payload.ms); } }), ); @@ -522,7 +522,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { // session, so the favorite-station star disappears. setCurrentRadioStation(null); setDurationMs(e.payload.duration_ms); - setPositionMs(0); + setPlayerPosition(0); // Refresh the device-side fields from the engine: WASAPI // exclusive mode may have reopened the stream at the new // track's native rate, and we want the AudioQualityFooter @@ -562,7 +562,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { e.payload.is_remote ? null : radioStationFromMetadata(e.payload), ); setDurationMs(e.payload.duration_ms ?? 0); - setPositionMs(0); + setPlayerPosition(0); // A remote track has a real cover behind a Bearer endpoint; // radio only carries the station favicon, so fetch the song's // album cover from Deezer instead. Either swaps in async. @@ -704,7 +704,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { // so the PlayerBar doesn't lag the invoke round-trip. setCurrentTrack(chosen); setDurationMs(chosen.duration_ms); - setPositionMs(0); + setPlayerPosition(0); setPlaybackState("loading"); try { @@ -759,7 +759,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) { const seek = useCallback(async (ms: number) => { // Optimistic: update the UI position immediately; the backend // will also emit player:position after the seek lands. - setPositionMs(ms); + setPlayerPosition(ms); try { await playerSeek(ms); } catch (err) { @@ -923,7 +923,6 @@ export function PlayerProvider({ children }: { children: ReactNode }) { isPlaying, currentTrack, currentRadioStation, - positionMs, durationMs, volume, setVolume, diff --git a/src/hooks/useKaraokeWordFill.ts b/src/hooks/useKaraokeWordFill.ts index 47093863..5a02ff34 100644 --- a/src/hooks/useKaraokeWordFill.ts +++ b/src/hooks/useKaraokeWordFill.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef } from "react"; import { usePlayer } from "./usePlayer"; +import { usePlayerPosition } from "./usePlayerPosition"; import { usePrefersReducedMotion } from "./usePrefersReducedMotion"; import type { LyricsWord } from "../lib/tauri/lyrics"; @@ -43,7 +44,8 @@ function now(): number { * playback is paused mid-word. */ export function useKaraokeWordFill(word: LyricsWord | null | undefined) { - const { positionMs, isPlaying, playbackSpeed } = usePlayer(); + const { isPlaying, playbackSpeed } = usePlayer(); + const positionMs = usePlayerPosition(); const reduceMotion = usePrefersReducedMotion(); const elRef = useRef(null); diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index a43523bb..809885a6 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -83,7 +83,6 @@ interface PlayerContextValue { * star can save the station even after an ICY title overwrote the * track line. `null` when the current source isn't Web Radio. */ currentRadioStation: PluginFavorite | null; - positionMs: number; durationMs: number; // Volume: UI-owned slider (0-100) debounced into the backend. diff --git a/src/hooks/usePlayerPosition.ts b/src/hooks/usePlayerPosition.ts new file mode 100644 index 00000000..e9cefd8a --- /dev/null +++ b/src/hooks/usePlayerPosition.ts @@ -0,0 +1,42 @@ +import { useSyncExternalStore } from "react"; + +/** + * The playback position, kept outside `PlayerContext`. + * + * The backend emits `player:position` four times a second. While the + * position lived in the context value, every one of those ticks re-rendered + * each of the context's consumers — the whole library view among them — to + * redraw what a handful of components show. Here only the components that + * call {@link usePlayerPosition} re-render on a tick; everything else that + * reads the player sees a context that changes when the player does. + * + * Module state, one per webview: the main window, the mini-player and the + * lyrics overlay each run their own `PlayerProvider`, which is the only + * writer of its window's copy. + */ +let positionMs = 0; +const listeners = new Set<() => void>(); + +/** Written by `PlayerProvider` only: the backend's ticks, seeks, resets. */ +export function setPlayerPosition(ms: number): void { + if (ms === positionMs) return; + positionMs = ms; + for (const listener of listeners) listener(); +} + +/** The position now, for code that needs it in a handler, not a render. */ +export function getPlayerPosition(): number { + return positionMs; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** The playback position in ms; re-renders the caller on every tick. */ +export function usePlayerPosition(): number { + return useSyncExternalStore(subscribe, getPlayerPosition); +} diff --git a/src/hooks/useTrackLyrics.ts b/src/hooks/useTrackLyrics.ts index c87c459e..89f0eac4 100644 --- a/src/hooks/useTrackLyrics.ts +++ b/src/hooks/useTrackLyrics.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { usePlayer } from "./usePlayer"; +import { usePlayerPosition } from "./usePlayerPosition"; import { useEstimatedKaraoke } from "./useEstimatedKaraoke"; import { estimateLineWords } from "../lib/lyricsWordEstimate"; import { isRadioTrack, isRemoteTrack } from "../lib/playerSources"; @@ -113,7 +114,8 @@ export interface TrackLyrics { export function useTrackLyrics(): TrackLyrics { const { t } = useTranslation(); - const { currentTrack, positionMs, seek } = usePlayer(); + const { currentTrack, seek } = usePlayer(); + const positionMs = usePlayerPosition(); const [payload, setPayload] = useState(null); const [isFetching, setIsFetching] = useState(false);