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 package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions src/mini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<MotionConfig reducedMotion={reduce ? "always" : "user"}>
<MiniPlayer />
{skin === "classic" ? <MiniPlayer /> : <SkinnedMiniPlayer skin={skin} />}
</MotionConfig>
);
}
109 changes: 4 additions & 105 deletions src/ui/components/mini-player/MiniPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();

Expand Down Expand Up @@ -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<PlayerSync>({
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<TimeSync>({ currentTime: 0, duration: 0 });
const [volumeState, setVolumeState] = useState<VolumeSync>({ muted: false, volume: 1 });
const [seekPreviewTime, setSeekPreviewTime] = useState<number | null>(null);
const [volumePreview, setVolumePreview] = useState<number | null>(null);
const [cachedArtwork, setCachedArtwork] = useState<string | null>(null);
const hoverAction = useMiniPlayerHoverAction();
const expandedRef = useRef(false);
const dragTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
Expand Down Expand Up @@ -295,35 +270,6 @@ export default function MiniPlayer() {
};
}, []);

useEffect(() => {
const setup = async () => {
const unlisten = await listen<PlayerSync>("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) {
Expand All @@ -339,19 +285,6 @@ export default function MiniPlayer() {
};
}, []);

useEffect(() => {
const setup = async () => {
const unlisten = await listen<TimeSync>("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;
Expand Down Expand Up @@ -381,19 +314,6 @@ export default function MiniPlayer() {
}
}, [hoverAction]);

useEffect(() => {
const setup = async () => {
const unlisten = await listen<VolumeSync>("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 }) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
126 changes: 126 additions & 0 deletions src/ui/components/mini-player/SkinPicker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="group"
aria-labelledby={labelledBy}
className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(11rem,1fr))]"
>
{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 (
<div
key={skin.id}
className={cn(
"relative flex flex-col gap-2 rounded-2xl p-3 transition-colors",
isSelected ? "bg-primary/15" : "hover:bg-muted",
)}
>
{/* Decorative: the skins' own buttons must not be reachable from here. */}
<div
className="pointer-events-none grid place-items-center overflow-hidden"
style={{ height: PREVIEW_HEIGHT }}
aria-hidden="true"
inert
>
{/* 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. */}
<div style={{ width: width * scale, height: height * scale }}>
<div
className="grid place-items-center"
style={{ width, height, transform: `scale(${scale})`, transformOrigin: "top left" }}
>
{Skin ? <Skin {...preview} /> : <ClassicPreview {...preview} />}
</div>
</div>
</div>
<span className="flex flex-col gap-0.5">
<strong className="text-sm font-medium text-foreground">{skin.name}</strong>
<span className="text-xs text-muted-foreground">{skin.description}</span>
</span>
{/* A sibling overlay rather than a wrapping button: the preview holds buttons,
and a button inside a button is invalid. */}
<button
type="button"
aria-pressed={isSelected}
aria-label={`${skin.name} skin`}
onClick={() => setMiniPlayerSkin(skin.id)}
className="absolute inset-0 rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
);
})}
</div>
);
}

/** Classic owns its window logic, so its card shows a still of the collapsed capsule. */
function ClassicPreview({ title, artist, artworkUrl }: MiniSkinProps) {
return (
<div className="flex h-11 w-40 items-center gap-2 rounded-full bg-popover pl-1.5 pr-3 ring-1 ring-border">
<TrackArtwork
artworkUrl={artworkUrl ?? undefined}
className="size-[34px] shrink-0 rounded-full bg-muted"
iconSize={15}
size={34}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-semibold text-popover-foreground">{title}</span>
<span className="block truncate text-[10px] text-muted-foreground">{artist}</span>
</span>
</div>
);
}
Loading
Loading