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
4 changes: 2 additions & 2 deletions docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Entry points in the [`PlayerBar`](../../src/components/player/PlayerBar.tsx): cl

**Sourcing — the bound server.** A track playing from the server has no local file, so neither of the two above applies: `track.id` is a **negative sentinel minted per playback**, and asking `get_track_canvas` about it would be querying the local database for a rowid that does not exist. [`useTrackCanvas`](../../src/hooks/useTrackCanvas.ts) branches on `remote_id` instead and asks [`remote::canvas`](../../src-tauri/crates/app/src/remote/canvas.rs), which mints a **ticket** (`POST /tracks/{id}/canvas-ticket`) and returns a self-authorising `/api/v2/canvas-stream/{ticket}` URL — a `<video src>` cannot send a Bearer header, the same problem remote audio solves the same way. The URL is always built from **our** base and a relative reply is required, since an absolute one would point the webview at a host the user never authenticated against. A server clip takes the rung the manual local one holds (somebody put it on that library on purpose); no Canvas on the server is an ordinary 404 that falls through to the plugin. The ticket expires within the hour, which is why nothing caches it durably — and the per-playback sentinel makes that automatic, since replaying the track mints a fresh one. Full rationale in [RFC-005](../rfcs/RFC-005-remote-source-and-sync-v2.md#a-server-tracks-canvas-rides-a-ticket-like-its-audio).

**"Show Canvas" toggle** — a [`CanvasToggleButton`](../../src/components/player/CanvasToggleButton.tsx) (Spotify's control) appears in the immersive top bar **and** the `NowPlayingPanel` header **only when the current track has a Canvas** and motion isn't reduced, so it is never a dead control. It drives the global [`useCanvasEnabled`](../../src/hooks/useCanvasEnabled.ts) preference (localStorage, **default OFF** — the cover shows first, the clip takes over on click, matching "click Show Canvas to reveal"). `prefers-reduced-motion` ([`usePrefersReducedMotion`](../../src/hooks/usePrefersReducedMotion.ts)) suppresses the clip and hides the toggle; radio (negative sentinel id) and Spotify tracks are excluded. Setting a Canvas is immersive-only in v1 (the panel only reflects + toggles).
**"Show Canvas" toggle** — a [`CanvasToggleButton`](../../src/components/player/CanvasToggleButton.tsx) (Spotify's control) appears in the immersive top bar, the `NowPlayingPanel` header **and** the [mini-player](#mini-player)'s top bar **only when the current track has a Canvas** and motion isn't reduced, so it is never a dead control. It drives the global [`useCanvasEnabled`](../../src/hooks/useCanvasEnabled.ts) preference (localStorage, **default OFF** — the cover shows first, the clip takes over on click, matching "click Show Canvas to reveal"). `prefers-reduced-motion` ([`usePrefersReducedMotion`](../../src/hooks/usePrefersReducedMotion.ts)) suppresses the clip and hides the toggle; radio (negative sentinel id) and Spotify tracks are excluded. Setting a Canvas is immersive-only in v1 (the panel only reflects + toggles).

**The clip's own frame (#694)** — Canvas is a 9:16 medium, and `object-cover` into the square cover frame drops about 44 % of the clip's height, split evenly top and bottom: any clip whose subject is not in the vertical middle comes out beheaded. It is not a crop to nudge, so in the `NowPlayingPanel` the frame **takes the clip's shape instead of the cover's**. [`CanvasStage`](../../src/components/player/CanvasStage.tsx) reports `videoWidth / videoHeight` through `onAspect`, tagged with the path it measured so an answer about a clip the surface has moved off is ignored rather than applied to the next one; it fires on `canplay`, not `loadedmetadata`, so the frame changing shape and the video fading in are one movement instead of a square cover sitting in an already-tall box. The frame is sized by a **definite width** (`min(100%, 56vh × ratio)`) with `aspect-ratio` deriving the height — a `max-height` cap would just squash the box out of ratio — and only a clip meaningfully taller than square takes it over. The still cover behind fills the tall frame rather than sitting square at the top, and the title / artist / album block moves **onto the clip's lower part over a scrim**, the way the reference does it: with the square frame gone nothing else holds that space, and white on a scrim is the only pair that is safe over colours we cannot know in advance. **The immersive hero stays square**: that column does not scroll and the hero shares its height with the metadata and the transport, so a frame tall enough for a vertical clip would push the controls off a short window.

Expand Down Expand Up @@ -210,7 +210,7 @@ The request was for the appearance choice Chrome offers on Linux, and it does no
- **Persistent bounds** — position + size are persisted in `app_setting['mini_player.bounds']` (JSON blob, machine-level) via debounced `onMoved` / `onResized` listeners in [`MiniPlayer.tsx`](../../src/components/views/MiniPlayer.tsx) (300 ms after the last gesture so SQLite isn't hammered at 60 Hz while dragging). On open, [`miniPlayer.ts::openMiniPlayer`](../../src/lib/miniPlayer.ts) restores the saved rectangle when it still overlaps an available monitor by at least 80 px on both axes (`availableMonitors()` check guards against monitor disconnects / resolution changes). Otherwise it falls back to anchoring bottom-right of the primary monitor (`currentMonitor` → physical size ÷ scale factor → logical px) with a 24 px edge margin so the OS taskbar / Dock isn't covered.
- **Routing** — same Vite bundle, branched in [`main.tsx`](../../src/main.tsx) on `?mini=1` so the mini boots into a stripped-down provider tree (`Theme + Profile + Player` only — no `Library` / `Playlist` since the widget never browses).
- **Cover-derived background** — [`lib/dominantColor.ts`](../../src/lib/dominantColor.ts) draws the artwork onto a 64×64 canvas, samples every 4th pixel, skips near-monochrome runs (white margins, black bars) so the average reflects the real hue, and produces a 3-stop gradient applied to the window background.
- **Canvas and motion cover (#717)** — the same chain as the panel and the immersive view, Canvas > motion cover > slideshow > still cover, with the same gates (the Show Canvas toggle, `prefers-reduced-motion`). The clip is cropped into the square slot rather than given a tall frame, which would push the controls out of a 280-pixel window. It is a second video decode while the main window may be showing the same clip. The toggle itself lives in `localStorage`, which the two webviews share but whose in-memory copy they do not, so [`useCanvasEnabled`](../../src/hooks/useCanvasEnabled.ts) listens for `storage` events: a toggle in the main window reaches an open mini-player without reopening it.
- **Canvas and motion cover (#717)** — the same chain as the panel and the immersive view, Canvas > motion cover > slideshow > still cover, with the same gates (the Show Canvas toggle, `prefers-reduced-motion`), and the same toggle in its top bar. The clip is cropped into the square slot rather than given a tall frame, which would push the controls out of a 280-pixel window. It is a second video decode while the main window may be showing the same clip. The toggle itself lives in `localStorage`, which the two webviews share but whose in-memory copy they do not, so [`useCanvasEnabled`](../../src/hooks/useCanvasEnabled.ts) listens for `storage` events: a toggle in the main window reaches an open mini-player without reopening it.
- **Hover overlay controls** — shuffle / prev / play (white round Spotify-style) / next / repeat fade in over the cover, with a compact **volume slider + mute** on a second row (#511); idle state shows just the artwork. The overlay also reveals on `focus-within` — its controls stay in the tab order, and a keyboard user shouldn't be driving a slider they can't see.
- **Shared state is broadcast, not per-window** — one engine behind two `PlayerContext`s, so anything reachable from both windows travels as an event: `player:volume-changed` from [`player_set_volume`](../../src-tauri/crates/app/src/commands/player.rs), `player:options-changed` from the shuffle / repeat commands, and `track:liked-changed` from [`toggle_like_track`](../../src-tauri/crates/app/src/commands/track.rs) (#523). The heart is shared through [`useLikedTracks`](../../src/hooks/useLikedTracks.ts), which the player bar and the mini-player both use rather than each keeping their own set. Only volume needs an echo guard — see [`invariants.md`](../architecture/invariants.md#events).
- **Drag region** — `data-tauri-drag-region` on the central dot strip, plus an explicit `getCurrentWindow().startDragging()` `onMouseDown` as a belt-and-suspenders fallback for the Windows hit-test races. Requires `core:window:allow-start-dragging` in the capability (not in `core:default`).
Expand Down
22 changes: 21 additions & 1 deletion src/components/views/MiniPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ import { CoverSlideshow } from "../player/CoverSlideshow";
import { CanvasStage } from "../player/CanvasStage";
import { MotionCoverOverlay } from "../player/MotionCoverOverlay";
import { useTrackCanvas } from "../../hooks/useTrackCanvas";
import { useCanvasEnabled } from "../../hooks/useCanvasEnabled";
import {
setCanvasEnabled,
useCanvasEnabled,
} from "../../hooks/useCanvasEnabled";
import { CanvasToggleButton } from "../player/CanvasToggleButton";
import { useAlbumMotionArtwork } from "../../hooks/useAlbumMotionArtwork";
import { resolveArtwork } from "../../lib/tauri/artwork";
import { dominantColor, darken, rgb } from "../../lib/dominantColor";
Expand Down Expand Up @@ -519,6 +523,22 @@ export function MiniPlayer() {
))}
</div>
<div className="flex items-center gap-0.5">
{/* The same Show Canvas toggle as the Now Playing panel, shown
under the same condition: only when the track has a clip and
motion is not reduced, so it is never a dead control. The
preference is shared, so it flips in the main window too. */}
{canvasPath && !reducedMotion && (
<CanvasToggleButton
enabled={canvasEnabled}
onToggle={() => setCanvasEnabled(!canvasEnabled)}
size={12}
className={`p-1 rounded-full transition-colors ${
canvasEnabled
? "text-emerald-400 hover:bg-white/10"
: "text-white/60 hover:text-white hover:bg-white/10"
}`}
/>
)}
<button
type="button"
onClick={() =>
Expand Down
Loading