fix: ui fixes

This commit is contained in:
gosangam
2026-06-11 00:32:04 +05:30
parent 4b159cb4d9
commit ead8ad02e8
16 changed files with 769 additions and 266 deletions
+101 -10
View File
@@ -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"