fix: ui fixes
This commit is contained in:
@@ -23,6 +23,8 @@ import {
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Volume1,
|
||||
Pin,
|
||||
PinOff,
|
||||
X,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
@@ -38,6 +40,8 @@ const MINIMIZE_DELAY = 8000;
|
||||
* Returns [minimized, setMinimized, resetTimer].
|
||||
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call
|
||||
* `resetTimer()` on any user interaction to restart the countdown.
|
||||
* While `pinned`, the countdown never schedules — the player stays
|
||||
* expanded until the user minimizes it manually.
|
||||
*
|
||||
* The `hasTrack`-driven reset of `minimized` is done via the
|
||||
* "adjust state during render based on previous prop" pattern from the
|
||||
@@ -46,7 +50,7 @@ const MINIMIZE_DELAY = 8000;
|
||||
* fine because it fires asynchronously (the rule is about *synchronous*
|
||||
* setState during the effect body).
|
||||
*/
|
||||
function useAutoMinimize(hasTrack: boolean) {
|
||||
function useAutoMinimize(hasTrack: boolean, pinned: boolean) {
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [lastHasTrack, setLastHasTrack] = useState(hasTrack);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -68,8 +72,9 @@ function useAutoMinimize(hasTrack: boolean) {
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer();
|
||||
if (pinned) return;
|
||||
timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY);
|
||||
}, [clearTimer]);
|
||||
}, [clearTimer, pinned]);
|
||||
|
||||
// Drive the timer from hasTrack. The effect body itself only calls
|
||||
// start/clear (which schedule, not mutate state); the actual
|
||||
@@ -106,6 +111,28 @@ const MINI_POS_STORAGE_KEY = "spsays:audio-mini-pos";
|
||||
// the click-to-expand that would otherwise fire on pointer-up.
|
||||
const MINI_DRAG_THRESHOLD = 4;
|
||||
|
||||
// Pinned state persistence — pinning prevents both auto-minimize paths
|
||||
// (inactivity timer + tap-outside), and the choice survives reloads.
|
||||
const PIN_STORAGE_KEY = "spsays:audio-pinned";
|
||||
|
||||
function loadPinned(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return localStorage.getItem(PIN_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function savePinned(pinned: boolean) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(PIN_STORAGE_KEY, pinned ? "1" : "0");
|
||||
} catch {
|
||||
// Storage may be unavailable — the pin still holds for this session.
|
||||
}
|
||||
}
|
||||
|
||||
type MiniPos = { x: number; y: number };
|
||||
|
||||
function clampMiniPos(p: MiniPos): MiniPos {
|
||||
@@ -424,8 +451,22 @@ export function FloatingPlayer() {
|
||||
|
||||
const playerRef = useRef<HTMLDivElement>(null);
|
||||
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
|
||||
const { minimized, setMinimized, resetTimer } =
|
||||
useAutoMinimize(!!currentTrack);
|
||||
const [pinned, setPinned] = useState(loadPinned);
|
||||
const { minimized, setMinimized, resetTimer } = useAutoMinimize(
|
||||
!!currentTrack,
|
||||
pinned,
|
||||
);
|
||||
|
||||
const togglePin = useCallback(() => {
|
||||
setPinned((p) => {
|
||||
const next = !p;
|
||||
savePinned(next);
|
||||
return next;
|
||||
});
|
||||
// Un-minimize + restart/clear the countdown under the new pin state
|
||||
// (the timer hook re-evaluates when `pinned` flips).
|
||||
resetTimer();
|
||||
}, [resetTimer]);
|
||||
|
||||
// Minimized pill position lives in motion values so framer's `drag`
|
||||
// can mutate them directly without round-tripping through React state.
|
||||
@@ -496,14 +537,19 @@ export function FloatingPlayer() {
|
||||
return () => observer.disconnect();
|
||||
}, [currentTrack]);
|
||||
|
||||
// Add bottom padding to body when player is visible
|
||||
// Reserve space under the page content while the FULL bar is on screen so
|
||||
// it doesn't cover the end of the page. Scoped to the expanded state: the
|
||||
// minimized pill floats and needs no clearance, and a track stays loaded
|
||||
// (pill present) until explicitly closed — leaving this padding keyed to
|
||||
// the track alone parked a permanent 96px dead band at the bottom of every
|
||||
// page (visible as a gap above the bottom nav) after one use of the player.
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
if (!currentTrack || minimized) return;
|
||||
document.body.style.paddingBottom = "96px";
|
||||
return () => {
|
||||
document.body.style.paddingBottom = "";
|
||||
};
|
||||
}, [currentTrack]);
|
||||
}, [currentTrack, minimized]);
|
||||
|
||||
// Wrap interactions to reset the minimize timer
|
||||
const interact = useCallback(
|
||||
@@ -523,9 +569,9 @@ export function FloatingPlayer() {
|
||||
const iPrevious = interact(previous);
|
||||
const iStop = interact(stop);
|
||||
|
||||
// Minimize when clicking/tapping outside the player
|
||||
// Minimize when clicking/tapping outside the player — unless pinned.
|
||||
useEffect(() => {
|
||||
if (!currentTrack || minimized) return;
|
||||
if (!currentTrack || minimized || pinned) return;
|
||||
|
||||
function handleOutsideClick(e: PointerEvent) {
|
||||
const player = playerRef.current;
|
||||
@@ -537,7 +583,7 @@ export function FloatingPlayer() {
|
||||
document.addEventListener("pointerdown", handleOutsideClick);
|
||||
return () =>
|
||||
document.removeEventListener("pointerdown", handleOutsideClick);
|
||||
}, [currentTrack, minimized, setMinimized]);
|
||||
}, [currentTrack, minimized, pinned, setMinimized]);
|
||||
|
||||
// Keyboard shortcuts when player is focused
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -900,6 +946,31 @@ export function FloatingPlayer() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Pin — keeps the player expanded (no auto-minimize) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePin}
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned ? "Unpin player" : "Pin player open"}
|
||||
title={
|
||||
pinned
|
||||
? "Unpin — player minimizes on its own again"
|
||||
: "Pin — keep the player from minimizing"
|
||||
}
|
||||
className={cn(
|
||||
"size-7 inline-flex items-center justify-center rounded-md transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
pinned
|
||||
? "text-primary bg-white/10 hover:bg-white/15"
|
||||
: "text-white/50 hover:text-white hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
{pinned ? (
|
||||
<PinOff className="size-4" />
|
||||
) : (
|
||||
<Pin className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Minimize (playing) or Close (paused) */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -963,6 +1034,26 @@ export function FloatingPlayer() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Pin — keeps the player expanded (no auto-minimize) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePin}
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned ? "Unpin player" : "Pin player open"}
|
||||
className={cn(
|
||||
"size-8 inline-flex items-center justify-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0",
|
||||
pinned
|
||||
? "text-primary bg-white/10 hover:bg-white/15"
|
||||
: "text-white/50 hover:text-white hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
{pinned ? (
|
||||
<PinOff className="size-4" />
|
||||
) : (
|
||||
<Pin className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Minimize (playing) or Close (paused) */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user