+ {/* Decorative: the skins' own buttons must not be reachable from here. */}
+
+ {/* Boxed at the scaled size: scale() alone keeps the full-size layout box, which
+ overflows the card and gets aligned to its top edge instead of centred. */}
+
+
+ {Skin ? : }
+
+
+
+
+ {skin.name}
+ {skin.description}
+
+ {/* A sibling overlay rather than a wrapping button: the preview holds buttons,
+ and a button inside a button is invalid. */}
+
+ );
+ })}
+
+ );
+}
+
+/** Classic owns its window logic, so its card shows a still of the collapsed capsule. */
+function ClassicPreview({ title, artist, artworkUrl }: MiniSkinProps) {
+ return (
+
+
+
+ {title}
+ {artist}
+
+
+ );
+}
diff --git a/src/ui/components/mini-player/SkinnedMiniPlayer.tsx b/src/ui/components/mini-player/SkinnedMiniPlayer.tsx
new file mode 100644
index 00000000..72a5e85c
--- /dev/null
+++ b/src/ui/components/mini-player/SkinnedMiniPlayer.tsx
@@ -0,0 +1,138 @@
+import { useEffect, useRef, useState, type MouseEvent, type WheelEvent } from "react";
+import { emit } from "@tauri-apps/api/event";
+import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
+import {
+ getMiniPlayerSkinInfo,
+ saveMiniPlayerPosition,
+ type MiniPlayerSkinId,
+} from "../../settings/miniPlayer";
+import { useReduceMotion } from "../../settings/renderEffects";
+import { isLinux } from "../../platform";
+import { restoreMainWindow, useMiniPlayerBridge } from "./useMiniPlayerBridge";
+import { SKIN_COMPONENTS } from "./skins";
+
+const win = getCurrentWindow();
+const INTERACTIVE_SELECTOR = "button, input, a, [role='button']";
+const SEEK_STEP_SEC = 5;
+const VOLUME_STEP = 0.05;
+const VOLUME_READOUT_MS = 1000;
+
+/**
+ * Runs a fixed-size skin in the mini window: sizes and drags the window, and wires the skin
+ * to playback. Skins themselves only draw, which is what lets Settings preview them.
+ */
+export function SkinnedMiniPlayer({ skin }: { skin: Exclude }) {
+ const { playerState, timeState, volumeState, artworkUrl } = useMiniPlayerBridge();
+ const reduceMotion = useReduceMotion();
+ const Skin = SKIN_COMPONENTS[skin];
+ // The volume just asked for, shown until the main window's sync catches up.
+ const [pendingVolume, setPendingVolume] = useState(null);
+ const readoutTimerRef = useRef(undefined);
+ const volume = pendingVolume ?? (volumeState.muted ? 0 : volumeState.volume);
+ // Read by the keyboard and wheel handlers, which must not rebind on every time tick.
+ const latestRef = useRef({ time: 0, duration: 0, volume: 1 });
+ latestRef.current = { time: timeState.currentTime, duration: timeState.duration, volume };
+
+ const setVolume = (value: number) => {
+ const next = Math.round(Math.min(1, Math.max(0, value)) * 100) / 100;
+ latestRef.current.volume = next;
+ setPendingVolume(next);
+ void emit("mini-player:volume", { volume: next });
+ window.clearTimeout(readoutTimerRef.current);
+ readoutTimerRef.current = window.setTimeout(() => setPendingVolume(null), VOLUME_READOUT_MS);
+ };
+
+ const nudgeVolume = (delta: number) => setVolume(latestRef.current.volume + delta);
+
+ const seekBy = (delta: number) => {
+ const { time, duration } = latestRef.current;
+ if (duration <= 0) return;
+ void emit("mini-player:seek", { time: Math.min(duration, Math.max(0, time + delta)) });
+ };
+
+ /*
+ * Space, ←/→ and ↑/↓ work on any skin without each one wiring them up. A focused button or
+ * slider keeps its own keys, so Space on the play key never toggles twice.
+ */
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.target instanceof Element && event.target.closest("input, button")) return;
+ const action = {
+ " ": () => void emit("mini-player:toggle-play-pause"),
+ ArrowLeft: () => seekBy(-SEEK_STEP_SEC),
+ ArrowRight: () => seekBy(SEEK_STEP_SEC),
+ ArrowUp: () => nudgeVolume(VOLUME_STEP),
+ ArrowDown: () => nudgeVolume(-VOLUME_STEP),
+ }[event.key];
+ if (!action) return;
+ event.preventDefault();
+ action();
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.clearTimeout(readoutTimerRef.current);
+ };
+ }, []);
+
+ // Proportional, so a trackpad's small deltas fine-tune and a wheel notch moves ~5%.
+ const handleWheel = (event: WheelEvent) => {
+ nudgeVolume(Math.max(-0.1, Math.min(0.1, -event.deltaY / 2000)));
+ };
+
+ useEffect(() => {
+ const { width, height } = getMiniPlayerSkinInfo(skin);
+ void win.setSize(new LogicalSize(width, height)).catch(() => {});
+ // Classic leaves the window click-through between hovers; a skin is solid throughout.
+ if (!isLinux) void win.setIgnoreCursorEvents(false).catch(() => {});
+ }, [skin]);
+
+ useEffect(() => {
+ const unlisten = win.onMoved(({ payload }) => {
+ const position = { x: payload.x, y: payload.y };
+ saveMiniPlayerPosition(position);
+ void emit("mini-player:position-changed", position);
+ });
+ return () => {
+ void unlisten.then((stop) => stop());
+ };
+ }, []);
+
+ const handleMouseDown = (event: MouseEvent) => {
+ // A skin claims a press (the vinyl's rim seek) by preventing it.
+ if (event.button !== 0 || event.defaultPrevented) return;
+ if (event.target instanceof Element && event.target.closest(INTERACTIVE_SELECTOR)) return;
+ event.preventDefault();
+ void win.startDragging().catch(() => {});
+ };
+
+ return (
+
+ );
+}
+
+/** A hub hole in the label: the tape pack around a toothed, turning hub. */
+function HubHole({ pack, spinRef }: { pack: number; spinRef: Ref }) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+/** The same pack seen through the centre window: only its curved edge reaches in. */
+function Pack({ size, centerX }: { size: number; centerX: number }) {
+ return (
+
+ );
+}
+
+function CapstanHole() {
+ return ;
+}
+
+function Screw({ className }: { className: string }) {
+ return (
+
+
+
+ );
+}
+
+/** A deck key: it sinks when pressed. */
+function DeckKey({
+ label,
+ onClick,
+ wide = false,
+ children,
+}: {
+ label: string;
+ onClick: () => void;
+ wide?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function LabelButton({
+ label,
+ onClick,
+ children,
+}: {
+ label: string;
+ onClick: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/src/ui/components/mini-player/skins/VinylSkin.tsx b/src/ui/components/mini-player/skins/VinylSkin.tsx
new file mode 100644
index 00000000..b13827b5
--- /dev/null
+++ b/src/ui/components/mini-player/skins/VinylSkin.tsx
@@ -0,0 +1,341 @@
+import { useId, useRef, useState, type PointerEvent, type ReactNode } from "react";
+import { cn } from "@/lib/utils";
+import { SpinnerSteps } from "@/components/motion/loader";
+import {
+ ArrowUpIcon,
+ CloseIcon,
+ PauseActiveIcon,
+ PlayActiveIcon,
+ SkipNextIcon,
+ SkipPreviousIcon,
+} from "@/ui/icons";
+import { TrackArtwork } from "../../TrackArtwork";
+import {
+ clampProgress,
+ formatSkinTime,
+ SKIN_FOCUS,
+ useSeekPreview,
+ useSpin,
+ type MiniSkinProps,
+} from "./shared";
+
+/*
+ * Tonearm angles, clockwise from straight down. The pivot sits in the top-right corner, so a
+ * larger angle swings the stylus inward: ~3° on the outer groove, ~26° by the label.
+ */
+const ARM_REST = -10;
+const ARM_START = 3;
+const ARM_SWEEP = 23;
+/** Presses this far out (as a share of the radius) grab the needle instead of the window. */
+const SEEK_BAND_INNER = 0.8;
+
+/** Curved text cannot truncate itself, so long titles are cut to what the arc holds. */
+function fitArc(text: string, max: number): string {
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
+}
+
+/**
+ * A turntable on a brushed-metal deck. The tonearm tracks the song from the outer groove to
+ * the label, and dragging around the rim drops the needle anywhere in it.
+ */
+export function VinylSkin({
+ title,
+ artist,
+ artworkUrl,
+ isPlaying,
+ isLoading,
+ isError,
+ currentTime,
+ duration,
+ volume,
+ volumeVisible,
+ reduceMotion,
+ onTogglePlay,
+ onNext,
+ onPrevious,
+ onSeek,
+ onRestore,
+ onClose,
+}: MiniSkinProps) {
+ // useId can contain characters that are not safe in an SVG `href` fragment.
+ const arcId = `vinyl-${useId().replace(/[^\w-]/g, "")}`;
+ const seek = useSeekPreview(currentTime, onSeek);
+ const [dragging, setDragging] = useState(false);
+ const lastProgressRef = useRef(null);
+ const deckRef = useRef(null);
+ const recordRef = useSpin(isPlaying, reduceMotion, 1.8);
+
+ const shownTime = seek.preview ?? currentTime;
+ const progress = clampProgress(shownTime, duration);
+ const hasTrack = title !== null;
+ const armAngle = hasTrack ? ARM_START + ARM_SWEEP * progress : ARM_REST;
+ // The arm rises off the record whenever it is not playing, as a real one does.
+ const lifted = !isPlaying || dragging;
+ const hud = volumeVisible
+ ? `Vol ${Math.round(volume * 100)}%`
+ : dragging
+ ? `${formatSkinTime(shownTime)} / ${formatSkinTime(duration)}`
+ : duration > 0
+ ? `-${formatSkinTime(duration - shownTime)}`
+ : null;
+
+ const pointerOnDeck = (event: PointerEvent) => {
+ const rect = deckRef.current?.getBoundingClientRect();
+ if (!rect) return null;
+ const dx = event.clientX - (rect.left + rect.width / 2);
+ const dy = event.clientY - (rect.top + rect.height / 2);
+ return {
+ radius: Math.hypot(dx, dy) / (rect.width / 2),
+ progress: (Math.atan2(dx, -dy) / (2 * Math.PI) + 1) % 1,
+ };
+ };
+
+ const handlePointerDown = (event: PointerEvent) => {
+ const point = pointerOnDeck(event);
+ if (!point || duration <= 0 || point.radius < SEEK_BAND_INNER || point.radius > 1) return;
+ // Prevented so the host never sees a mousedown here and starts dragging the window.
+ event.preventDefault();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ lastProgressRef.current = point.progress;
+ setDragging(true);
+ seek.setPreview(point.progress * duration);
+ };
+
+ const handlePointerMove = (event: PointerEvent) => {
+ const point = pointerOnDeck(event);
+ if (!point) return;
+ if (!dragging) {
+ const onBand = duration > 0 && point.radius >= SEEK_BAND_INNER && point.radius <= 1;
+ event.currentTarget.style.cursor = onBand ? "pointer" : "";
+ return;
+ }
+ let next = point.progress;
+ const last = lastProgressRef.current;
+ // Crossing twelve o'clock pins to the start or end instead of wrapping round.
+ if (last !== null && Math.abs(next - last) > 0.5) next = last > 0.5 ? 1 : 0;
+ lastProgressRef.current = next;
+ seek.setPreview(next * duration);
+ };
+
+ const finishDrag = (commit: boolean) => {
+ if (!dragging) return;
+ setDragging(false);
+ lastProgressRef.current = null;
+ if (commit && seek.preview !== null) seek.commit(seek.preview);
+ else seek.setPreview(null);
+ };
+
+ return (
+
finishDrag(true)}
+ onPointerCancel={() => finishDrag(false)}
+ >
+ {/* Keyboard and screen readers seek here; the rim is the pointer's way in. */}
+ seek.setPreview(Number(event.currentTarget.value))}
+ onKeyUp={(event) => seek.commit(Number(event.currentTarget.value))}
+ aria-label="Song position"
+ aria-valuetext={`${formatSkinTime(shownTime)} of ${formatSkinTime(duration)}`}
+ className="peer sr-only"
+ />
+
+ {/* Brushed aluminium: fine radial strokes, a glint, and a bevelled edge. */}
+
+
+ {/* Progress on the rim: a faint track with the elapsed share lit over it. */}
+
+
+
+
+
+
+
+
+ {/* Two fixed sheens and a lit rim: light stays put while the record turns under it. */}
+
+
+
+
+
+ {hud}
+
+
+ {/* Tonearm, pivoting from the corner outside the record. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Transport over the label: out of the way until you reach for it. */}
+