diff --git a/apps/web/core/debates/browse/debate-feed-player.tsx b/apps/web/core/debates/browse/debate-feed-player.tsx index 4d18c52596..af727929af 100644 --- a/apps/web/core/debates/browse/debate-feed-player.tsx +++ b/apps/web/core/debates/browse/debate-feed-player.tsx @@ -18,7 +18,7 @@ import { Avatar } from '~/design-system/avatar'; import { RetrySmall } from '~/design-system/icons/retry-small'; import { Text } from '~/design-system/text'; -import { Play, Speaker, SpeakerMuted } from './icons'; +import { Pause, Play, Speaker, SpeakerMuted } from './icons'; import { WinnerVoteButton } from './winner-vote-button'; type DebateFeedPlayerProps = { @@ -100,6 +100,25 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb const showReplay = ready && playbackEnded && !hasVoted; const showPausedGlyph = ready && userPaused && !playbackEnded; + // Clicking the video briefly flashes the action it just took — feedback only, not a control. + const [flash, setFlash] = React.useState<{ icon: 'play' | 'pause'; visible: boolean }>({ + icon: 'play', + visible: false, + }); + const flashTimeoutRef = React.useRef | null>(null); + React.useEffect( + () => () => { + if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current); + }, + [] + ); + const toggleFromVideo = () => { + setFlash({ icon: playing ? 'pause' : 'play', visible: true }); + if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current); + flashTimeoutRef.current = setTimeout(() => setFlash(current => ({ ...current, visible: false })), 600); + togglePlayback(); + }; + return (
- - - ) : ready ? ( - // Feed debates autoplay muted, so the unmute control stays visible during - // playback — otherwise there's no way to hear audio. Once unmuted it recedes - // to hover-only. - { - measurement.control(mutedByUser ? 'unmute' : 'mute'); - setMutedByUser(current => !current); - }} - className={ - mutedByUser - ? undefined - : 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100' - } - > - {mutedByUser ? : } - + ready ? ( +
+ {/* Desktop: a persistent play/pause beside the mute control. Mobile keeps the + centred paused glyph and tap-to-toggle instead. */} + + {playing ? : } + + {showReplay ? ( + + + + ) : ( + // Feed debates autoplay muted, so the unmute control stays visible during + // playback — otherwise there's no way to hear audio. Once unmuted it recedes + // to hover-only on desktop; touch has no hover, so on mobile it stays visible + // or there'd be no way to find it again. + { + measurement.control(mutedByUser ? 'unmute' : 'mute'); + setMutedByUser(current => !current); + }} + className={ + mutedByUser + ? undefined + : 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 md:opacity-100' + } + > + {mutedByUser ? : } + + )} +
) : null } /> @@ -150,7 +183,7 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb mutedByUser={mutedByUser} isResuming={isResuming} onPlaybackTick={onPlaybackTick} - onToggle={togglePlayback} + onToggle={toggleFromVideo} votes={votes} scrubber={ ready ? ( @@ -176,17 +209,29 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb } /> + {/* Mobile only — desktop has the persistent play/pause beside the mute control. */} {showPausedGlyph && ( )} + {/* Desktop only — mobile already shows the centred paused glyph in this spot. */} +
+ {flash.icon === 'pause' ? : } +
+ {error && ( {error} @@ -386,7 +431,10 @@ function ControlCircle({ event.stopPropagation(); onClick(); }} - className={cx('grid size-8 place-items-center rounded-full bg-white text-text shadow-light', className)} + className={cx( + 'grid size-10.5 place-items-center rounded-full bg-white text-text shadow-light [&>svg]:scale-[1.3]', + className + )} > {children} diff --git a/apps/web/core/debates/use-debate-playback.test.tsx b/apps/web/core/debates/use-debate-playback.test.tsx index 36120506d6..9af5cc2817 100644 --- a/apps/web/core/debates/use-debate-playback.test.tsx +++ b/apps/web/core/debates/use-debate-playback.test.tsx @@ -1,5 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; +import * as React from 'react'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Debate, DebateMediaTurnSegment } from './api'; @@ -106,6 +108,39 @@ describe('useDebatePlayback — playback URLs survive re-activation (GEO-2895)', await waitFor(() => expect(result.current.urls.slot1).not.toBeNull()); }); + + // StrictMode (on by default in dev) mounts, cleans up and re-runs every effect before the + // first fetch settles. The re-run must not be skipped as "already fetched", or the cancelled + // first run drops the URLs and the card sits on "Loading…" forever. + it('loads URLs under StrictMode', async () => { + const { result } = renderHook(() => useDebatePlayback(debateFixture(), true), { wrapper: React.StrictMode }); + + await waitFor(() => expect(result.current.urls.slot1).not.toBeNull()); + expect(result.current.urls.slot2).not.toBeNull(); + }); + + it('loads URLs when the card is scrolled away and back before the first fetch settles', async () => { + let settle: () => void = () => {}; + const gate = new Promise(resolve => { + settle = resolve; + }); + mocks.recordingUrl.mockImplementation(async ({ filename }: { filename: string }) => { + await gate; + return { url: `https://cdn.test/${filename}?sig=abc` }; + }); + + const debate = debateFixture(); + const { result, rerender } = renderHook(({ active }) => useDebatePlayback(debate, active), { + initialProps: { active: true }, + }); + + rerender({ active: false }); + rerender({ active: true }); + await act(async () => settle()); + + await waitFor(() => expect(result.current.urls.slot1).not.toBeNull()); + expect(result.current.urls.slot2).not.toBeNull(); + }); }); /** diff --git a/apps/web/core/debates/use-debate-playback.ts b/apps/web/core/debates/use-debate-playback.ts index 9ce9a88d9a..f6011401e0 100644 --- a/apps/web/core/debates/use-debate-playback.ts +++ b/apps/web/core/debates/use-debate-playback.ts @@ -271,6 +271,11 @@ export function useDebatePlayback(debate: Debate, enabled: boolean) { // // `useRecordingUrl` is a mutation rather than a query, so nothing upstream caches this — // every discarded URL is a real round trip. + // + // The key is claimed only once URLs are committed, never while a request is in flight. A + // cleanup that lands mid-flight (StrictMode's dev double-run, or scrolling away and back + // before the fetch settles) cancels that run, and a claim taken up front would make the + // re-run skip as "already fetched" — leaving the card on "Loading…" forever. const fetchedForRef = React.useRef(null); React.useEffect(() => { @@ -286,10 +291,7 @@ export function useDebatePlayback(debate: Debate, enabled: boolean) { if (fetchedForRef.current === recordingsKey) return; let cancelled = false; - fetchedForRef.current = recordingsKey; - const releaseKey = () => { - if (fetchedForRef.current === recordingsKey) fetchedForRef.current = null; - }; + fetchedForRef.current = null; setUrls({ slot1: null, slot2: null }); // A different debate's clocks start over; carrying this across would strand the new one // at the old one's position. @@ -301,17 +303,14 @@ export function useDebatePlayback(debate: Debate, enabled: boolean) { getRecordingPlaybackUrlRef.current({ debateId: debate.id, filename: slot2RecordingFilename }), ]) .then(([slot1Result, slot2Result]) => { - // Scrolled away mid-flight: nothing is committed, so release the key or the card - // would hold a claim on URLs it never received and never fetch again. - if (cancelled) { - releaseKey(); - return; - } + // Cancelled mid-flight: commit nothing and leave the key unclaimed so the next run + // fetches again. + if (cancelled) return; + fetchedForRef.current = recordingsKey; setUrls({ slot1: slot1Result.url, slot2: slot2Result.url }); }) .catch(caught => { - // Same on failure, otherwise one error leaves the card permanently on "Loading…". - releaseKey(); + // The key was never claimed, so the next activation retries. if (!cancelled) setError(caught instanceof Error ? caught.message : 'Could not load recordings.'); });