diff --git a/components/audio-player/floating-player.tsx b/components/audio-player/floating-player.tsx index c76300c3..79f0bc81 100644 --- a/components/audio-player/floating-player.tsx +++ b/components/audio-player/floating-player.tsx @@ -1,6 +1,13 @@ "use client"; -import { useRef, useCallback, useEffect, useMemo, useState } from "react"; +import { + useRef, + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; import { useAudioPlayer } from "./audio-context"; import { AudioWaveAnimation } from "./audio-wave"; import { cn } from "@/lib/utils"; @@ -12,6 +19,8 @@ import { Pause, SkipBack, SkipForward, + RotateCcw, + RotateCw, Volume2, VolumeX, Volume1, @@ -26,27 +35,66 @@ import { const MINIMIZE_DELAY = 8000; +/** + * `useSyncExternalStore` is the React-recommended way to expose values + * from external systems (window, media-queries, etc.) without a + * mirror-into-state effect. The previous useEffect-+-setState version + * tripped React 19's "setState synchronously inside an effect" warning + * — this variant has no effect at all. + * + * On the server we default to false (desktop). The player only mounts + * when a track is playing, which is always after hydration, so the + * initial-paint value never matters for SSR markup. + */ +const MOBILE_QUERY = "(max-width: 767px)"; + +function subscribeMobile(callback: () => void): () => void { + const mq = window.matchMedia(MOBILE_QUERY); + mq.addEventListener("change", callback); + return () => mq.removeEventListener("change", callback); +} + +function getMobileSnapshot(): boolean { + return window.matchMedia(MOBILE_QUERY).matches; +} + +function getMobileServerSnapshot(): boolean { + return false; +} + function useIsMobile() { - const [mobile, setMobile] = useState(false); - useEffect(() => { - const mq = window.matchMedia("(max-width: 767px)"); - setMobile(mq.matches); - const handler = (e: MediaQueryListEvent) => setMobile(e.matches); - mq.addEventListener("change", handler); - return () => mq.removeEventListener("change", handler); - }, []); - return mobile; + return useSyncExternalStore( + subscribeMobile, + getMobileSnapshot, + getMobileServerSnapshot, + ); } /** * Returns [minimized, setMinimized, resetTimer]. - * Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity on mobile. - * Call `resetTimer()` on any user interaction to restart the countdown. + * Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call + * `resetTimer()` on any user interaction to restart the countdown. + * + * The `hasTrack`-driven reset of `minimized` is done via the + * "adjust state during render based on previous prop" pattern from the + * React docs — not via a setState-in-effect, which would trip the + * cascading-render warning. The setTimeout's setMinimized(true) is + * fine because it fires asynchronously (the rule is about *synchronous* + * setState during the effect body). */ function useAutoMinimize(hasTrack: boolean) { const [minimized, setMinimized] = useState(false); + const [lastHasTrack, setLastHasTrack] = useState(hasTrack); const timerRef = useRef | null>(null); + // Reset `minimized` when hasTrack transitions. Done during render — + // no effect required, and the two setState calls only fire when the + // comparison actually flips. + if (hasTrack !== lastHasTrack) { + setLastHasTrack(hasTrack); + setMinimized(false); + } + const clearTimer = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current); @@ -59,22 +107,24 @@ function useAutoMinimize(hasTrack: boolean) { timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY); }, [clearTimer]); - const resetTimer = useCallback(() => { - setMinimized(false); - startTimer(); - }, [startTimer]); - - // Start timer when track begins + // Drive the timer from hasTrack. The effect body itself only calls + // start/clear (which schedule, not mutate state); the actual + // `setMinimized(true)` lives in the timer callback and fires + // asynchronously — outside the effect body, so the rule doesn't apply. useEffect(() => { if (hasTrack) { startTimer(); } else { clearTimer(); - setMinimized(false); } return clearTimer; }, [hasTrack, startTimer, clearTimer]); + const resetTimer = useCallback(() => { + setMinimized(false); + startTimer(); + }, [startTimer]); + return { minimized, setMinimized, resetTimer } as const; } @@ -222,10 +272,17 @@ function ProgressBar({ }, []); if (thin) { + // The wrapper is intentionally taller than the visible bar so the + // absolutely-positioned thumb has room to sit centred without + // poking out of the player card's overflow-hidden clip box. + // + // wrapper h-4 (16px) ┐ + // visible bar h-1.5 │ centred via flex + // thumb size-3 ┘ sits on the bar, absolutely positioned return (
+
+
+
); @@ -318,6 +381,52 @@ function TransportButton({ ); } +// --------------------------------------------------------------------------- +// Seek-by-5s Button +// --------------------------------------------------------------------------- + +/** + * Quick ±5s seek button — circular-arrow icon with a small "5" overlay + * (the standard "skip 5 seconds" affordance in modern audio players). + * Lives right beside Play/Pause and is the primary navigation control + * within a track now that the prev/next-track buttons are hidden in + * single-track contexts (which is currently every context — no caller + * passes a real playlist). + */ +function SeekFiveButton({ + direction, + onClick, + className, +}: { + direction: "back" | "forward"; + onClick: () => void; + className?: string; +}) { + const Icon = direction === "back" ? RotateCcw : RotateCw; + const label = + direction === "back" ? "Skip back 5 seconds" : "Skip forward 5 seconds"; + return ( + + ); +} + // --------------------------------------------------------------------------- // Floating Player // --------------------------------------------------------------------------- @@ -511,9 +620,19 @@ export function FloatingPlayer() { const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1; const hasPrev = currentIndex > 0; + // Prev/Next-track buttons only make sense when there's an actual + // playlist with more than one entry. Everywhere `play(track)` is + // called we end up with a single-track playlist, which makes both + // buttons permanently disabled — confusing dead controls. Hide them + // until a caller invokes `playPlaylist(tracks)` with multiple tracks. + const hasPlaylist = playlist.length > 1; const coverSrc = currentTrack.coverImage || FALLBACK_COVER; const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + const seekBack5 = () => iSeekTo(Math.max(0, currentTime - 5)); + const seekForward5 = () => + iSeekTo(Math.min(duration || currentTime + 5, currentTime + 5)); + // ── Hide entirely when selection toolbar is active ── if (selectionToolbarVisible) return null; @@ -706,16 +825,26 @@ export function FloatingPlayer() {
- {/* Center: Transport controls */} + {/* Center: Transport controls. Order: + [prev track]? -5s Play/Pause +5s [next track]? + Prev/next only render when a real playlist is active. */}
- - - + {hasPlaylist ? ( + + + + ) : null} + + - - - + + + {hasPlaylist ? ( + + + + ) : null}
{/* Right: Volume + Source link + Close */} @@ -867,16 +1004,26 @@ export function FloatingPlayer() { - {/* Row 2: Prev + Play/Pause + Next — centered */} -
- - - + {/* Row 2 — centered transport. Order mirrors the desktop: + [prev]? -5s Play/Pause +5s [next]? + Prev/next render only when a real playlist is active. */} +
+ {hasPlaylist ? ( + + + + ) : null} + + - - - + + + {hasPlaylist ? ( + + + + ) : null}
diff --git a/components/social/collection-scroll-card.tsx b/components/social/collection-scroll-card.tsx index 40279e05..7ee3c67d 100644 --- a/components/social/collection-scroll-card.tsx +++ b/components/social/collection-scroll-card.tsx @@ -222,10 +222,11 @@ function CoverSlide({ className="object-cover" unoptimized /> - {/* Full-coverage scrim — even the top carries ~40% black so the - badge and any white text overlaid up there stay readable - without looking blended with the cover image. */} -
+ {/* Light scrim — enough darkness at the bottom for the title + and post-count line to read, but the top stays close to + the original image so the cover photo isn't muted into a + near-black plate. */} +
) : (
diff --git a/components/social/post-card.tsx b/components/social/post-card.tsx index 09008024..488c6209 100644 --- a/components/social/post-card.tsx +++ b/components/social/post-card.tsx @@ -46,15 +46,23 @@ export function PostCard({ return (
- {/* Reshare attribution — only on rows the feed owner reshared. - The author block below still shows the original author; this - strip just signals "this is on this feed because it was - reshared," matching the Twitter/X retweet convention. */} - {post.reshared_at ? ( -
+ {/* Reshare attribution — only on rows that are themselves + reshares. The link goes to the original author's profile; + their handle is rendered inline so the source is obvious. + The post body below shows the resharer as the author (this + IS their post — likes, comments, and background all belong + to them, independent from the original). */} + {post.reshared_from_post_id && post.original_author_handle ? ( + - Reshared -
+ + Reshared from{" "} + @{post.original_author_handle} + + ) : null} {/* Author header — identity on the left, overflow menu on the right. The menu trigger only renders when there are items to diff --git a/components/social/post-scroll-card.tsx b/components/social/post-scroll-card.tsx index d4aed2c7..0b9be11c 100644 --- a/components/social/post-scroll-card.tsx +++ b/components/social/post-scroll-card.tsx @@ -75,15 +75,22 @@ export function PostScrollCard({ id={`post-${post.id}`} className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24" > - {/* Reshare attribution — only on rows the feed owner reshared. - The author block below still names the original author; this - strip just signals why the post is in this feed. Matches the - Twitter/X retweet convention. */} - {post.reshared_at ? ( -
+ {/* Reshare attribution — only on rows that are themselves + reshares. Tappable link → original author's profile. The card + body below shows the resharer as the author (this row IS the + resharer's own post — likes, comments, background all belong + to them, decoupled from the original). */} + {post.reshared_from_post_id && post.original_author_handle ? ( + - Reshared -
+ + Reshared from{" "} + @{post.original_author_handle} + + ) : null} {/* Author header — identity on the left, three-dot overflow menu on the right (Delete for author, Block/Report for everyone diff --git a/lib/social.ts b/lib/social.ts index c2017609..498ab8c7 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -62,16 +62,21 @@ export type Post = { author_name?: string; author_handle?: string; author_avatar_url?: string | null; - /** True when the calling viewer has reshared this post. Populated by - * endpoints that go through OptionalAuth (single post, profile feed, - * collection contents). Always false / absent for anonymous viewers. - * Drives the post-card "Reshare ↔ Send" swap. */ + /** True when the calling viewer has reshared the canonical version + * of this post. Populated by endpoints that go through OptionalAuth + * (single post, profile feed, collection contents). Always false / + * absent for anonymous viewers. Drives the post-card "Reshare ↔ Send" + * swap. */ viewer_has_reshared?: boolean; - /** Non-null only on rows that appear in a profile feed because the - * profile owner reshared them. Drives the "↻ Reshared" header in - * the post card and the COALESCE(reshared_at, created_at) sort on - * the BFF. Authored posts leave this null. */ - reshared_at?: string | null; + /** Non-null only when this row is itself a reshare — points at the + * canonical original. The four `original_author_*` fields carry the + * canonical author's profile info for rendering the + * "Reshared from @handle" link in the card. Authored canonical posts + * leave all of these null/absent. */ + reshared_from_post_id?: string | null; + original_author_name?: string | null; + original_author_handle?: string | null; + original_author_avatar_url?: string | null; }; export type Bookmark = { @@ -153,8 +158,11 @@ export const posts = { * side. Resharing your own post is rejected with 400 (not currently * surfaced as a distinct error type because the UI only ever offers * Reshare on non-owned posts). */ + /** Materialise a reshare. Returns the new post (or the existing + * reshare row if the caller already reshared this canonical), so + * callers can drop it straight into local feed state. */ reshare(id: string) { - return call(`/posts/${id}/reshare`, { method: "POST" }); + return call(`/posts/${id}/reshare`, { method: "POST" }); }, /** Remove a previously recorded reshare. Idempotent. */ unreshare(id: string) {