fix: post reshare and audio player enhancements

This commit is contained in:
gosangam
2026-06-03 14:41:34 +05:30
parent ddc6d3c7bf
commit 48dd450a15
5 changed files with 266 additions and 87 deletions
+212 -57
View File
@@ -1,6 +1,13 @@
"use client"; "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 { useAudioPlayer } from "./audio-context";
import { AudioWaveAnimation } from "./audio-wave"; import { AudioWaveAnimation } from "./audio-wave";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -12,6 +19,8 @@ import {
Pause, Pause,
SkipBack, SkipBack,
SkipForward, SkipForward,
RotateCcw,
RotateCw,
Volume2, Volume2,
VolumeX, VolumeX,
Volume1, Volume1,
@@ -26,27 +35,66 @@ import {
const MINIMIZE_DELAY = 8000; 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() { function useIsMobile() {
const [mobile, setMobile] = useState(false); return useSyncExternalStore(
useEffect(() => { subscribeMobile,
const mq = window.matchMedia("(max-width: 767px)"); getMobileSnapshot,
setMobile(mq.matches); getMobileServerSnapshot,
const handler = (e: MediaQueryListEvent) => setMobile(e.matches); );
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return mobile;
} }
/** /**
* Returns [minimized, setMinimized, resetTimer]. * Returns [minimized, setMinimized, resetTimer].
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity on mobile. * Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call
* Call `resetTimer()` on any user interaction to restart the countdown. * `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) { function useAutoMinimize(hasTrack: boolean) {
const [minimized, setMinimized] = useState(false); const [minimized, setMinimized] = useState(false);
const [lastHasTrack, setLastHasTrack] = useState(hasTrack);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | 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(() => { const clearTimer = useCallback(() => {
if (timerRef.current) { if (timerRef.current) {
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
@@ -59,22 +107,24 @@ function useAutoMinimize(hasTrack: boolean) {
timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY); timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY);
}, [clearTimer]); }, [clearTimer]);
const resetTimer = useCallback(() => { // Drive the timer from hasTrack. The effect body itself only calls
setMinimized(false); // start/clear (which schedule, not mutate state); the actual
startTimer(); // `setMinimized(true)` lives in the timer callback and fires
}, [startTimer]); // asynchronously — outside the effect body, so the rule doesn't apply.
// Start timer when track begins
useEffect(() => { useEffect(() => {
if (hasTrack) { if (hasTrack) {
startTimer(); startTimer();
} else { } else {
clearTimer(); clearTimer();
setMinimized(false);
} }
return clearTimer; return clearTimer;
}, [hasTrack, startTimer, clearTimer]); }, [hasTrack, startTimer, clearTimer]);
const resetTimer = useCallback(() => {
setMinimized(false);
startTimer();
}, [startTimer]);
return { minimized, setMinimized, resetTimer } as const; return { minimized, setMinimized, resetTimer } as const;
} }
@@ -222,10 +272,17 @@ function ProgressBar({
}, []); }, []);
if (thin) { 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 ( return (
<div <div
ref={barRef} ref={barRef}
className="w-full h-2 bg-white/15 cursor-pointer shrink-0 relative" className="relative w-full h-4 cursor-pointer shrink-0 flex items-center group/seek touch-none"
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
@@ -236,9 +293,15 @@ function ProgressBar({
aria-valuenow={currentTime} aria-valuenow={currentTime}
tabIndex={0} tabIndex={0}
> >
<div className="w-full h-1.5 bg-white/15 overflow-hidden">
<div
className="h-full bg-primary transition-[width] duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
<div <div
className="h-full bg-primary transition-[width] duration-100 ease-linear" className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 size-3 bg-primary rounded-full ring-2 ring-[#1c1c1e] shadow-md pointer-events-none transition-transform group-hover/seek:scale-125"
style={{ width: `${progress}%` }} style={{ left: `${progress}%` }}
/> />
</div> </div>
); );
@@ -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 (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={cn(
"inline-flex items-center justify-center rounded-full transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
className,
)}
>
<span className="relative inline-flex items-center justify-center">
<Icon className="size-5" strokeWidth={2.2} />
<span className="absolute text-[8px] font-bold leading-none translate-y-px">
5
</span>
</span>
</button>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Floating Player // Floating Player
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -511,9 +620,19 @@ export function FloatingPlayer() {
const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1; const hasNext = currentIndex >= 0 && currentIndex < playlist.length - 1;
const hasPrev = currentIndex > 0; 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 coverSrc = currentTrack.coverImage || FALLBACK_COVER;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0; 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 ── // ── Hide entirely when selection toolbar is active ──
if (selectionToolbarVisible) return null; if (selectionToolbarVisible) return null;
@@ -706,16 +825,26 @@ export function FloatingPlayer() {
</div> </div>
</div> </div>
{/* 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. */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<TransportButton {hasPlaylist ? (
onClick={iPrevious} <TransportButton
disabled={!hasPrev && currentTime <= 3} onClick={iPrevious}
label="Previous track" disabled={!hasPrev && currentTime <= 3}
className="text-white/60 hover:text-white hover:bg-white/10" label="Previous track"
> className="text-white/60 hover:text-white hover:bg-white/10"
<SkipBack className="size-4" /> >
</TransportButton> <SkipBack className="size-4" />
</TransportButton>
) : null}
<SeekFiveButton
direction="back"
onClick={seekBack5}
className="size-8 text-white/70 hover:text-white hover:bg-white/10"
/>
<TransportButton <TransportButton
onClick={iTogglePlayPause} onClick={iTogglePlayPause}
@@ -732,14 +861,22 @@ export function FloatingPlayer() {
)} )}
</TransportButton> </TransportButton>
<TransportButton <SeekFiveButton
onClick={iNext} direction="forward"
disabled={!hasNext} onClick={seekForward5}
label="Next track" className="size-8 text-white/70 hover:text-white hover:bg-white/10"
className="text-white/60 hover:text-white hover:bg-white/10" />
>
<SkipForward className="size-4" /> {hasPlaylist ? (
</TransportButton> <TransportButton
onClick={iNext}
disabled={!hasNext}
label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10"
>
<SkipForward className="size-4" />
</TransportButton>
) : null}
</div> </div>
{/* Right: Volume + Source link + Close */} {/* Right: Volume + Source link + Close */}
@@ -867,16 +1004,26 @@ export function FloatingPlayer() {
</button> </button>
</div> </div>
{/* Row 2: Prev + Play/Pause + Next — centered */} {/* Row 2 — centered transport. Order mirrors the desktop:
<div className="flex items-center justify-center gap-4 py-1"> [prev]? -5s Play/Pause +5s [next]?
<TransportButton Prev/next render only when a real playlist is active. */}
onClick={iPrevious} <div className="flex items-center justify-center gap-3 py-1">
disabled={!hasPrev && currentTime <= 3} {hasPlaylist ? (
label="Previous track" <TransportButton
className="text-white/60 hover:text-white hover:bg-white/10 !size-10" onClick={iPrevious}
> disabled={!hasPrev && currentTime <= 3}
<SkipBack className="size-5" /> label="Previous track"
</TransportButton> className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
>
<SkipBack className="size-5" />
</TransportButton>
) : null}
<SeekFiveButton
direction="back"
onClick={seekBack5}
className="size-10 text-white/70 hover:text-white hover:bg-white/10"
/>
<TransportButton <TransportButton
onClick={iTogglePlayPause} onClick={iTogglePlayPause}
@@ -893,14 +1040,22 @@ export function FloatingPlayer() {
)} )}
</TransportButton> </TransportButton>
<TransportButton <SeekFiveButton
onClick={iNext} direction="forward"
disabled={!hasNext} onClick={seekForward5}
label="Next track" className="size-10 text-white/70 hover:text-white hover:bg-white/10"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10" />
>
<SkipForward className="size-5" /> {hasPlaylist ? (
</TransportButton> <TransportButton
onClick={iNext}
disabled={!hasNext}
label="Next track"
className="text-white/60 hover:text-white hover:bg-white/10 !size-10"
>
<SkipForward className="size-5" />
</TransportButton>
) : null}
</div> </div>
</div> </div>
</motion.div> </motion.div>
+5 -4
View File
@@ -222,10 +222,11 @@ function CoverSlide({
className="object-cover" className="object-cover"
unoptimized unoptimized
/> />
{/* Full-coverage scrim — even the top carries ~40% black so the {/* Light scrim — enough darkness at the bottom for the title
badge and any white text overlaid up there stay readable and post-count line to read, but the top stays close to
without looking blended with the cover image. */} the original image so the cover photo isn't muted into a
<div className="absolute inset-0 bg-linear-to-t from-black/95 via-black/70 to-black/40" /> near-black plate. */}
<div className="absolute inset-0 bg-linear-to-t from-black/75 via-black/40 to-black/15" />
</> </>
) : ( ) : (
<div className="absolute inset-0" style={quoteCardBgStyle()} /> <div className="absolute inset-0" style={quoteCardBgStyle()} />
+16 -8
View File
@@ -46,15 +46,23 @@ export function PostCard({
return ( return (
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5"> <article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
{/* Reshare attribution — only on rows the feed owner reshared. {/* Reshare attribution — only on rows that are themselves
The author block below still shows the original author; this reshares. The link goes to the original author's profile;
strip just signals "this is on this feed because it was their handle is rendered inline so the source is obvious.
reshared," matching the Twitter/X retweet convention. */} The post body below shows the resharer as the author (this
{post.reshared_at ? ( IS their post — likes, comments, and background all belong
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-3"> to them, independent from the original). */}
{post.reshared_from_post_id && post.original_author_handle ? (
<Link
href={`/u/${post.original_author_handle}`}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground mb-3 transition-colors"
>
<Repeat2 className="size-3.5" /> <Repeat2 className="size-3.5" />
<span>Reshared</span> <span>
</div> Reshared from{" "}
<span className="font-medium">@{post.original_author_handle}</span>
</span>
</Link>
) : null} ) : null}
{/* Author header — identity on the left, overflow menu on the {/* Author header — identity on the left, overflow menu on the
right. The menu trigger only renders when there are items to right. The menu trigger only renders when there are items to
+15 -8
View File
@@ -75,15 +75,22 @@ export function PostScrollCard({
id={`post-${post.id}`} id={`post-${post.id}`}
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24" 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. {/* Reshare attribution — only on rows that are themselves
The author block below still names the original author; this reshares. Tappable link → original author's profile. The card
strip just signals why the post is in this feed. Matches the body below shows the resharer as the author (this row IS the
Twitter/X retweet convention. */} resharer's own post — likes, comments, background all belong
{post.reshared_at ? ( to them, decoupled from the original). */}
<div className="flex items-center gap-1.5 px-4 pt-3 text-xs text-muted-foreground"> {post.reshared_from_post_id && post.original_author_handle ? (
<Link
href={`/u/${post.original_author_handle}`}
className="inline-flex items-center gap-1.5 px-4 pt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Repeat2 className="size-3.5" /> <Repeat2 className="size-3.5" />
<span>Reshared</span> <span>
</div> Reshared from{" "}
<span className="font-medium">@{post.original_author_handle}</span>
</span>
</Link>
) : null} ) : null}
{/* Author header — identity on the left, three-dot overflow menu {/* Author header — identity on the left, three-dot overflow menu
on the right (Delete for author, Block/Report for everyone on the right (Delete for author, Block/Report for everyone
+18 -10
View File
@@ -62,16 +62,21 @@ export type Post = {
author_name?: string; author_name?: string;
author_handle?: string; author_handle?: string;
author_avatar_url?: string | null; author_avatar_url?: string | null;
/** True when the calling viewer has reshared this post. Populated by /** True when the calling viewer has reshared the canonical version
* endpoints that go through OptionalAuth (single post, profile feed, * of this post. Populated by endpoints that go through OptionalAuth
* collection contents). Always false / absent for anonymous viewers. * (single post, profile feed, collection contents). Always false /
* Drives the post-card "Reshare ↔ Send" swap. */ * absent for anonymous viewers. Drives the post-card "Reshare ↔ Send"
* swap. */
viewer_has_reshared?: boolean; viewer_has_reshared?: boolean;
/** Non-null only on rows that appear in a profile feed because the /** Non-null only when this row is itself a reshare — points at the
* profile owner reshared them. Drives the "↻ Reshared" header in * canonical original. The four `original_author_*` fields carry the
* the post card and the COALESCE(reshared_at, created_at) sort on * canonical author's profile info for rendering the
* the BFF. Authored posts leave this null. */ * "Reshared from @handle" link in the card. Authored canonical posts
reshared_at?: string | null; * 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 = { export type Bookmark = {
@@ -153,8 +158,11 @@ export const posts = {
* side. Resharing your own post is rejected with 400 (not currently * side. Resharing your own post is rejected with 400 (not currently
* surfaced as a distinct error type because the UI only ever offers * surfaced as a distinct error type because the UI only ever offers
* Reshare on non-owned posts). */ * 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) { reshare(id: string) {
return call<void>(`/posts/${id}/reshare`, { method: "POST" }); return call<Post>(`/posts/${id}/reshare`, { method: "POST" });
}, },
/** Remove a previously recorded reshare. Idempotent. */ /** Remove a previously recorded reshare. Idempotent. */
unreshare(id: string) { unreshare(id: string) {