diff --git a/package-lock.json b/package-lock.json index ed0c2552..60a2cb8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zuno", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zuno", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@solar-icons/react": "^2.0.0-beta.0", diff --git a/src/mini.tsx b/src/mini.tsx index dbaed6c0..12a73432 100644 --- a/src/mini.tsx +++ b/src/mini.tsx @@ -5,7 +5,8 @@ import { MotionConfig } from "motion/react"; import "./ui/styles/global.css"; import { applyPlatformAttributes } from "./ui/platform"; import MiniPlayer from "./ui/components/mini-player/MiniPlayer"; -import { hydrateMiniPlayerSettings } from "./ui/settings/miniPlayer"; +import { SkinnedMiniPlayer } from "./ui/components/mini-player/SkinnedMiniPlayer"; +import { hydrateMiniPlayerSettings, useMiniPlayerSkin } from "./ui/settings/miniPlayer"; import { applyPaperPcMode, hydratePaperPcMode } from "./ui/settings/paperPcMode"; import { applyRenderEffects, @@ -34,9 +35,10 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( /** The MotionConfig App.tsx has, for the same reason: it is what reduces the beUI springs. */ function MiniPlayerRoot() { const reduce = useReduceMotion(); + const skin = useMiniPlayerSkin(); return ( - + {skin === "classic" ? : } ); } diff --git a/src/ui/components/mini-player/MiniPlayer.tsx b/src/ui/components/mini-player/MiniPlayer.tsx index e8ac3d07..6a37f052 100644 --- a/src/ui/components/mini-player/MiniPlayer.tsx +++ b/src/ui/components/mini-player/MiniPlayer.tsx @@ -10,9 +10,8 @@ import { type PointerEvent, } from "react"; import { cn } from "@/lib/utils"; -import { emit, listen } from "@tauri-apps/api/event"; +import { emit } from "@tauri-apps/api/event"; import { cursorPosition, getCurrentWindow, LogicalSize, PhysicalPosition } from "@tauri-apps/api/window"; -import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { SpinnerSteps } from "@/components/motion/loader"; import { ArrowUpIcon, @@ -26,23 +25,7 @@ import { saveMiniPlayerPosition, useMiniPlayerHoverAction } from "../../settings import { isLinux, isMacOS, isWindows } from "../../platform"; import { Marquee } from "@/components/motion/marquee"; import { TrackArtwork } from "../TrackArtwork"; - -interface PlayerSync { - status: string; - artworkUrl: string | null; - title: string | null; - artist: string | null; -} - -interface TimeSync { - currentTime: number; - duration: number; -} - -interface VolumeSync { - muted: boolean; - volume: number; -} +import { restoreMainWindow, useMiniPlayerBridge } from "./useMiniPlayerBridge"; const win = getCurrentWindow(); @@ -97,19 +80,11 @@ const MINI_BUTTON = "flex size-7 shrink-0 items-center justify-center rounded-full text-neutral-300 transition-all hover:bg-white/10 hover:text-white active:scale-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50"; export default function MiniPlayer() { - const [playerState, setPlayerState] = useState({ - status: "idle", - artworkUrl: null, - title: null, - artist: null, - }); + const { playerState, timeState, volumeState, artworkUrl } = useMiniPlayerBridge(); const [expanded, setExpanded] = useState(false); const [isDragging, setIsDragging] = useState(false); - const [timeState, setTimeState] = useState({ currentTime: 0, duration: 0 }); - const [volumeState, setVolumeState] = useState({ muted: false, volume: 1 }); const [seekPreviewTime, setSeekPreviewTime] = useState(null); const [volumePreview, setVolumePreview] = useState(null); - const [cachedArtwork, setCachedArtwork] = useState(null); const hoverAction = useMiniPlayerHoverAction(); const expandedRef = useRef(false); const dragTimerRef = useRef | null>(null); @@ -295,35 +270,6 @@ export default function MiniPlayer() { }; }, []); - useEffect(() => { - const setup = async () => { - const unlisten = await listen("player-state-sync", (event) => { - setPlayerState((previous) => { - if (event.payload.artworkUrl && event.payload.artworkUrl !== previous.artworkUrl) { - setCachedArtwork(event.payload.artworkUrl); - } - - return event.payload; - }); - }); - - /* - * Asked for only once the listener above is live. - * - * `player-state-sync` is emitted on change, not on a timer, so a window created after - * playback started never hears about the track already playing and sits on "Nothing - * playing" until the next track change. Requesting after subscribing, rather than before, - * is what keeps the reply from arriving before anything is listening for it. - */ - void emit("mini-player:request-sync"); - - return unlisten; - }; - - const cleanup = setup(); - return () => { cleanup.then((unlisten) => unlisten()); }; - }, []); - useEffect(() => { return () => { if (dragTimerRef.current) { @@ -339,19 +285,6 @@ export default function MiniPlayer() { }; }, []); - useEffect(() => { - const setup = async () => { - const unlisten = await listen("player-time-sync", (event) => { - setTimeState(event.payload); - }); - - return unlisten; - }; - - const cleanup = setup(); - return () => { cleanup.then((unlisten) => unlisten()); }; - }, []); - useEffect(() => { const pendingSeekTarget = pendingSeekTargetRef.current; if (pendingSeekTarget === null) return; @@ -381,19 +314,6 @@ export default function MiniPlayer() { } }, [hoverAction]); - useEffect(() => { - const setup = async () => { - const unlisten = await listen("player-volume-sync", (event) => { - setVolumeState(event.payload); - }); - - return unlisten; - }; - - const cleanup = setup(); - return () => { cleanup.then((unlisten) => unlisten()); }; - }, []); - useEffect(() => { const setup = async () => { const unlisten = await win.onMoved(({ payload }) => { @@ -521,27 +441,7 @@ export default function MiniPlayer() { }; }, []); - const handleRestore = async () => { - await emit("mini-player:restore-main"); - - /* - * Best-effort, and deliberately not awaited into the restore below. - * - * The main window answers that event by *destroying* this window rather than hiding it, - * so a hide issued here can land after the window is already gone and reject. Awaited, - * that rejection aborts the rest of this function and leaves the main window in the - * background — the click appears to do nothing. Kept only for the instant visual - * feedback while the destroy makes its way across. - */ - void win.hide().catch(() => {}); - - const mainWin = await WebviewWindow.getByLabel("main"); - if (mainWin) { - await mainWin.show(); - await mainWin.unminimize(); - await mainWin.setFocus(); - } - }; + const handleRestore = restoreMainWindow; const stopAlbumArtDrag = async (restoreIfClick: boolean) => { if (!macAlbumDragActiveRef.current) return; @@ -871,7 +771,6 @@ export default function MiniPlayer() { const isPlaying = playerState.status === "playing"; const isLoading = playerState.status === "loading"; - const artworkUrl = playerState.artworkUrl ?? cachedArtwork; const displayedVolume = volumePreview ?? (volumeState.muted ? 0 : volumeState.volume); const displayedTime = seekPreviewTime ?? timeState.currentTime; const sliderValue = hoverAction === "volume" ? displayedVolume : displayedTime; diff --git a/src/ui/components/mini-player/SkinPicker.tsx b/src/ui/components/mini-player/SkinPicker.tsx new file mode 100644 index 00000000..7df389aa --- /dev/null +++ b/src/ui/components/mini-player/SkinPicker.tsx @@ -0,0 +1,126 @@ +import { cn } from "@/lib/utils"; +import { + MINI_PLAYER_SKINS, + setMiniPlayerSkin, + useMiniPlayerSkin, +} from "../../settings/miniPlayer"; +import { useReduceMotion } from "../../settings/renderEffects"; +import { shallowEqual, usePlayerSelector } from "../../../player/playerStore"; +import { TrackArtwork } from "../TrackArtwork"; +import { SKIN_COMPONENTS, type MiniSkinProps } from "./skins"; + +const PREVIEW_HEIGHT = 124; +const PREVIEW_WIDTH = 152; +/** Room each skin leaves around itself in its window for the shadow. */ +const WINDOW_PADDING = 16; +const noop = () => {}; + +/** + * Skin choice, each previewed live with whatever is playing now — a skin is judged by how it + * looks with your music, not a placeholder. + */ +export function MiniPlayerSkinPicker({ labelledBy }: { labelledBy: string }) { + const selected = useMiniPlayerSkin(); + const reduceMotion = useReduceMotion(); + const now = usePlayerSelector( + (player) => ({ track: player.currentTrack, status: player.status }), + shallowEqual, + ); + const duration = now.track?.durationSec || 210; + const preview: MiniSkinProps = { + title: now.track?.title ?? "Midnight City", + artist: now.track?.artist ?? "M83", + artworkUrl: now.track?.artworkUrl ?? null, + isPlaying: now.status === "playing", + isLoading: false, + isError: false, + currentTime: duration * 0.4, + duration, + volume: 0.7, + volumeVisible: false, + reduceMotion, + onTogglePlay: noop, + onNext: noop, + onPrevious: noop, + onSeek: noop, + onVolume: noop, + onRestore: noop, + onClose: noop, + }; + + return ( +
+ {MINI_PLAYER_SKINS.map((skin) => { + const isSelected = skin.id === selected; + const Skin = skin.id === "classic" ? null : SKIN_COMPONENTS[skin.id]; + const width = skin.width - WINDOW_PADDING; + const height = skin.height - WINDOW_PADDING; + const scale = Math.min(1, PREVIEW_WIDTH / width, PREVIEW_HEIGHT / height); + + return ( +
+ {/* Decorative: the skins' own buttons must not be reachable from here. */} + + + {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 ( +
event.preventDefault()} + > + void emit("mini-player:toggle-play-pause")} + onNext={() => void emit("mini-player:skip-next")} + onPrevious={() => void emit("mini-player:skip-previous")} + onSeek={(time) => void emit("mini-player:seek", { time })} + onVolume={setVolume} + onRestore={() => void restoreMainWindow()} + onClose={() => void win.destroy()} + /> +
+ ); +} diff --git a/src/ui/components/mini-player/skins/CassetteSkin.tsx b/src/ui/components/mini-player/skins/CassetteSkin.tsx new file mode 100644 index 00000000..257d8eac --- /dev/null +++ b/src/ui/components/mini-player/skins/CassetteSkin.tsx @@ -0,0 +1,366 @@ +import { type CSSProperties, type ReactNode, type Ref } 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, + SkinTitle, + useSeekPreview, + useSpin, + type MiniSkinProps, +} from "./shared"; + +/* + * Tape geometry. Each pack is one circle centred on its hub, so the same circle fills the hub + * hole and shows as a curved edge in the centre window — the way a real tape looks through its + * label. The hub centres sit HUB_OFFSET outside the window's edges. + */ +const HOLE = 34; +const WINDOW_WIDTH = 72; +const WINDOW_HEIGHT = 24; +const WINDOW_GAP = 6; +const HUB_OFFSET = WINDOW_GAP + HOLE / 2; +const PACK_MIN = 24; +const PACK_MAX = 84; +const VOLUME_SEGMENTS = 10; + +function packSize(share: number): number { + return PACK_MIN + (PACK_MAX - PACK_MIN) * share; +} + +/** + * A compact cassette. The tape winds from the left spool to the right as the song plays — in + * the hub holes and across the window — and the head plate along the bottom holds the deck. + */ +export function CassetteSkin({ + title, + artist, + artworkUrl, + isPlaying, + isLoading, + isError, + currentTime, + duration, + volume, + volumeVisible, + reduceMotion, + onTogglePlay, + onNext, + onPrevious, + onSeek, + onRestore, + onClose, +}: MiniSkinProps) { + const seek = useSeekPreview(currentTime, onSeek); + const shownTime = seek.preview ?? currentTime; + const progress = clampProgress(shownTime, duration); + const hasTrack = title !== null; + const canSeek = duration > 0; + // Hubs turn while the tape moves, and keep turning while a song loads. + const spinning = isPlaying || isLoading; + const leftHub = useSpin(spinning, reduceMotion, 2.4); + const rightHub = useSpin(spinning, reduceMotion, 2.4); + const leftPack = packSize(1 - progress); + const rightPack = packSize(progress); + const status = isError + ? "Couldn't play this song" + : isLoading + ? "Loading…" + : artist ?? (hasTrack ? "" : "Side A · insert a song"); + const led = isError + ? "bg-(--skin-led-error) shadow-[0_0_6px_var(--skin-led-error)]" + : isLoading + ? "bg-(--skin-led-busy) shadow-[0_0_6px_var(--skin-led-busy)] motion-safe:animate-pulse" + : isPlaying + ? "bg-(--skin-led-on) shadow-[0_0_6px_var(--skin-led-on)]" + : "bg-(--skin-led-off)"; + + return ( +
+ + + + + + {/* The label: a sticker inset clear of the screws, with the window cut through it. */} +
+
+ + {/* The head plate: the tapered foot of a real cassette, carrying the transport. */} +
+
+
+ ); +} + +/** 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 ( +