Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion src/components/common/LyricsEditorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<HTMLDivElement>(isOpen, onClose);

const [mode, setMode] = useState<Mode>("plain");
Expand Down
5 changes: 3 additions & 2 deletions src/components/player/AbLoopButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,7 +24,7 @@ import {
*/
export function AbLoopButton() {
const { t } = useTranslation();
const { positionMs, currentTrack } = usePlayer();
const { currentTrack } = usePlayer();
const [snap, setSnap] = useState<AbLoopSnapshot>({ a_ms: null, b_ms: null });

useEffect(() => {
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/components/player/HeldNote.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/components/player/ProgressBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<number | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);

Expand Down
3 changes: 2 additions & 1 deletion src/components/views/MiniPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -97,7 +98,6 @@ export function MiniPlayer() {
togglePlayback,
next,
previous,
positionMs,
durationMs,
repeatMode,
cycleRepeatMode,
Expand All @@ -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);
Expand Down
17 changes: 8 additions & 9 deletions src/contexts/PlayerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -168,7 +169,6 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
// track plays or playback goes idle.
const [currentRadioStation, setCurrentRadioStation] =
useState<PluginFavorite | null>(null);
const [positionMs, setPositionMs] = useState(0);
const [durationMs, setDurationMs] = useState(0);

// Volume: local + debounced backend push
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -459,7 +459,7 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
unlisten.push(
await listen<PlayerPositionPayload>("player:position", (e) => {
if (!isSeekingRef.current) {
setPositionMs(e.payload.ms);
setPlayerPosition(e.payload.ms);
}
}),
);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -923,7 +923,6 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
isPlaying,
currentTrack,
currentRadioStation,
positionMs,
durationMs,
volume,
setVolume,
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useKaraokeWordFill.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<HTMLElement | null>(null);
Expand Down
1 change: 0 additions & 1 deletion src/hooks/usePlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions src/hooks/usePlayerPosition.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 3 additions & 1 deletion src/hooks/useTrackLyrics.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<LyricsPayload | null>(null);
const [isFetching, setIsFetching] = useState(false);
Expand Down
Loading