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
+178 -23
View File
@@ -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<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(() => {
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 (
<div
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}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
@@ -236,11 +293,17 @@ function ProgressBar({
aria-valuenow={currentTime}
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
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={{ left: `${progress}%` }}
/>
</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
// ---------------------------------------------------------------------------
@@ -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,8 +825,11 @@ export function FloatingPlayer() {
</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">
{hasPlaylist ? (
<TransportButton
onClick={iPrevious}
disabled={!hasPrev && currentTime <= 3}
@@ -716,6 +838,13 @@ export function FloatingPlayer() {
>
<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
onClick={iTogglePlayPause}
@@ -732,6 +861,13 @@ export function FloatingPlayer() {
)}
</TransportButton>
<SeekFiveButton
direction="forward"
onClick={seekForward5}
className="size-8 text-white/70 hover:text-white hover:bg-white/10"
/>
{hasPlaylist ? (
<TransportButton
onClick={iNext}
disabled={!hasNext}
@@ -740,6 +876,7 @@ export function FloatingPlayer() {
>
<SkipForward className="size-4" />
</TransportButton>
) : null}
</div>
{/* Right: Volume + Source link + Close */}
@@ -867,8 +1004,11 @@ export function FloatingPlayer() {
</button>
</div>
{/* Row 2: Prev + Play/Pause + Next — centered */}
<div className="flex items-center justify-center gap-4 py-1">
{/* Row 2 — centered transport. Order mirrors the desktop:
[prev]? -5s Play/Pause +5s [next]?
Prev/next render only when a real playlist is active. */}
<div className="flex items-center justify-center gap-3 py-1">
{hasPlaylist ? (
<TransportButton
onClick={iPrevious}
disabled={!hasPrev && currentTime <= 3}
@@ -877,6 +1017,13 @@ export function FloatingPlayer() {
>
<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
onClick={iTogglePlayPause}
@@ -893,6 +1040,13 @@ export function FloatingPlayer() {
)}
</TransportButton>
<SeekFiveButton
direction="forward"
onClick={seekForward5}
className="size-10 text-white/70 hover:text-white hover:bg-white/10"
/>
{hasPlaylist ? (
<TransportButton
onClick={iNext}
disabled={!hasNext}
@@ -901,6 +1055,7 @@ export function FloatingPlayer() {
>
<SkipForward className="size-5" />
</TransportButton>
) : null}
</div>
</div>
</motion.div>
+5 -4
View File
@@ -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. */}
<div className="absolute inset-0 bg-linear-to-t from-black/95 via-black/70 to-black/40" />
{/* 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. */}
<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()} />
+16 -8
View File
@@ -46,15 +46,23 @@ export function PostCard({
return (
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
{/* 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 ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-3">
{/* 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 ? (
<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" />
<span>Reshared</span>
</div>
<span>
Reshared from{" "}
<span className="font-medium">@{post.original_author_handle}</span>
</span>
</Link>
) : null}
{/* Author header — identity on the left, overflow menu on the
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}`}
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 ? (
<div className="flex items-center gap-1.5 px-4 pt-3 text-xs text-muted-foreground">
{/* 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 ? (
<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" />
<span>Reshared</span>
</div>
<span>
Reshared from{" "}
<span className="font-medium">@{post.original_author_handle}</span>
</span>
</Link>
) : null}
{/* Author header — identity on the left, three-dot overflow menu
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_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<void>(`/posts/${id}/reshare`, { method: "POST" });
return call<Post>(`/posts/${id}/reshare`, { method: "POST" });
},
/** Remove a previously recorded reshare. Idempotent. */
unreshare(id: string) {