ui fixes
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect, useState } from "react";
|
||||
import { useRef, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAudioPlayer } from "./audio-context";
|
||||
import { AudioWaveAnimation } from "./audio-wave";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { motion, AnimatePresence, useMotionValue } from "framer-motion";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
@@ -84,6 +84,59 @@ function useAutoMinimize(hasTrack: boolean) {
|
||||
|
||||
const FALLBACK_COVER = "/images/cover.png";
|
||||
|
||||
// Minimized pill (size-14 = 3.5rem = 56px) drag + persistence.
|
||||
const MINI_PILL_SIZE = 56;
|
||||
const MINI_EDGE_MARGIN = 16; // matches the old `right-4 bottom-6` resting state
|
||||
const MINI_POS_STORAGE_KEY = "spsays:audio-mini-pos";
|
||||
// Past this drag distance (px), treat the gesture as "drag" and suppress
|
||||
// the click-to-expand that would otherwise fire on pointer-up.
|
||||
const MINI_DRAG_THRESHOLD = 4;
|
||||
|
||||
type MiniPos = { x: number; y: number };
|
||||
|
||||
function clampMiniPos(p: MiniPos): MiniPos {
|
||||
if (typeof window === "undefined") return p;
|
||||
const maxX = window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN;
|
||||
const maxY = window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN;
|
||||
return {
|
||||
x: Math.max(MINI_EDGE_MARGIN, Math.min(maxX, p.x)),
|
||||
y: Math.max(MINI_EDGE_MARGIN, Math.min(maxY, p.y)),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMiniPos(): MiniPos {
|
||||
// Resting position when nothing's been persisted yet: bottom-right
|
||||
// corner, matching the old static layout (`right-4 bottom-6`).
|
||||
if (typeof window === "undefined") return { x: 0, y: 0 };
|
||||
return {
|
||||
x: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
|
||||
y: window.innerHeight - MINI_PILL_SIZE - 24,
|
||||
};
|
||||
}
|
||||
|
||||
function loadMiniPos(): MiniPos | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(MINI_POS_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw);
|
||||
if (typeof p?.x !== "number" || typeof p?.y !== "number") return null;
|
||||
return clampMiniPos(p);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveMiniPos(p: MiniPos) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(MINI_POS_STORAGE_KEY, JSON.stringify(p));
|
||||
} catch {
|
||||
// Storage may be unavailable (Safari private mode, quota). Drop the
|
||||
// write silently — drag-and-position still works for the session.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -295,6 +348,57 @@ export function FloatingPlayer() {
|
||||
const { minimized, setMinimized, resetTimer } =
|
||||
useAutoMinimize(!!currentTrack);
|
||||
|
||||
// Minimized pill position lives in motion values so framer's `drag`
|
||||
// can mutate them directly without round-tripping through React state.
|
||||
// Initial position is computed once (lazy useMemo) — the player only
|
||||
// renders when a track is playing, which is always after hydration, so
|
||||
// window access is safe here.
|
||||
const initialMiniPos = useMemo(
|
||||
() => loadMiniPos() ?? defaultMiniPos(),
|
||||
[],
|
||||
);
|
||||
const miniX = useMotionValue(initialMiniPos.x);
|
||||
const miniY = useMotionValue(initialMiniPos.y);
|
||||
// Drag constraints — recomputed each render so a window resize between
|
||||
// renders gets picked up the next time the user touches the pill.
|
||||
const [miniBounds, setMiniBounds] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return { top: 0, left: 0, right: 0, bottom: 0 };
|
||||
}
|
||||
return {
|
||||
top: MINI_EDGE_MARGIN,
|
||||
left: MINI_EDGE_MARGIN,
|
||||
right: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
|
||||
bottom: window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
|
||||
};
|
||||
});
|
||||
// Re-clamp on window resize so a saved position from a wider viewport
|
||||
// doesn't strand the pill off-screen after rotation / DevTools open.
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
setMiniBounds({
|
||||
top: MINI_EDGE_MARGIN,
|
||||
left: MINI_EDGE_MARGIN,
|
||||
right: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
|
||||
bottom: window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
|
||||
});
|
||||
const clamped = clampMiniPos({ x: miniX.get(), y: miniY.get() });
|
||||
miniX.set(clamped.x);
|
||||
miniY.set(clamped.y);
|
||||
saveMiniPos(clamped);
|
||||
}
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [miniX, miniY]);
|
||||
// Tracks whether the most-recent pointer interaction was a drag (vs a
|
||||
// tap) so the click handler can suppress expand-on-drag-end.
|
||||
const miniWasDraggedRef = useRef(false);
|
||||
|
||||
// Watch for the selection toolbar appearing/disappearing in the DOM
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
@@ -422,24 +526,64 @@ export function FloatingPlayer() {
|
||||
data-audio-player
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Suppress the click that fires at the end of every drag —
|
||||
// only an actual tap should expand the player.
|
||||
if (miniWasDraggedRef.current) {
|
||||
miniWasDraggedRef.current = false;
|
||||
return;
|
||||
}
|
||||
setMinimized(false);
|
||||
resetTimer();
|
||||
}}
|
||||
aria-label="Expand audio player"
|
||||
initial={{ opacity: 0, scale: 0.5, bottom: -60 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
bottom: selectionToolbarVisible ? 90 : 24,
|
||||
drag
|
||||
dragMomentum={false}
|
||||
dragElastic={0}
|
||||
dragConstraints={miniBounds}
|
||||
onDragStart={() => {
|
||||
miniWasDraggedRef.current = false;
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.5, bottom: -60 }}
|
||||
onDragEnd={(_, info) => {
|
||||
// Distinguish a real drag from the small jitter that a tap
|
||||
// sometimes produces — only the former should suppress click.
|
||||
if (
|
||||
Math.abs(info.offset.x) > MINI_DRAG_THRESHOLD ||
|
||||
Math.abs(info.offset.y) > MINI_DRAG_THRESHOLD
|
||||
) {
|
||||
miniWasDraggedRef.current = true;
|
||||
}
|
||||
const clamped = clampMiniPos({
|
||||
x: miniX.get(),
|
||||
y: miniY.get(),
|
||||
});
|
||||
miniX.set(clamped.x);
|
||||
miniY.set(clamped.y);
|
||||
saveMiniPos(clamped);
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 400 }}
|
||||
style={{
|
||||
// Position purely via the motion-value transform so framer's
|
||||
// drag controller mutates the same channel that paints it.
|
||||
// `top: 0; left: 0` anchors the transform origin to the
|
||||
// viewport corner; `x`/`y` then carry the pill anywhere.
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
x: miniX,
|
||||
y: miniY,
|
||||
}}
|
||||
className={cn(
|
||||
"fixed z-40 right-4",
|
||||
"z-40",
|
||||
"size-14 rounded-full",
|
||||
"shadow-2xl shadow-black/30",
|
||||
"overflow-hidden",
|
||||
"ring-2 ring-white/20",
|
||||
// Grab affordance + touch-action: none so the browser doesn't
|
||||
// intercept touch as a page scroll and starve the drag.
|
||||
"cursor-grab active:cursor-grabbing touch-none",
|
||||
)}
|
||||
>
|
||||
{/* Cover image */}
|
||||
|
||||
Reference in New Issue
Block a user