Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions apps/web/core/debates/browse/debate-feed-player.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { render } from '@testing-library/react';

import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { Debate, DebateParticipant } from '~/core/debates/api';
import type { DebateVotesResult } from '~/core/debates/use-debate-votes';

import { DebateFeedPlayer } from './debate-feed-player';

const mocks = vi.hoisted(() => ({ controller: null as unknown }));

vi.mock('~/core/debates/use-debate-playback', () => ({
useDebatePlayback: () => mocks.controller,
}));

vi.mock('~/core/debates/use-playback-analytics', () => ({
usePlaybackAnalytics: () => ({ elementRef: { current: null }, control: vi.fn() }),
}));

vi.mock('~/core/hooks/use-entity-side-panel', () => ({
useEntitySidePanel: () => ({ openSidePanel: vi.fn(), closeSidePanel: vi.fn(), sidePanelTarget: null }),
}));

vi.mock('~/core/hooks/use-space', () => ({
useSpace: () => ({ space: null }),
}));

const participant = (slot: 1 | 2): DebateParticipant =>
({
participant_slot: slot,
profile_space_id: `space-${slot}`,
position_label: slot === 1 ? 'For' : 'Against',
}) as unknown as DebateParticipant;

const votes: DebateVotesResult = {
sharePercentFor: () => null,
isMyPick: () => false,
hasVoted: false,
isVoting: false,
castVote: async () => {},
};

/**
* A controller in the one state that matters here: playing, with a turn in progress, both
* recordings loaded. `mutedByUser` and `turnState` are what the audio gating reads.
*/
function controllerFixture(overrides: { mutedByUser: boolean; turnSlot: 1 | 2; isResuming?: boolean }) {
return {
slot1VideoRef: { current: null },
slot2VideoRef: { current: null },
slot1Participant: participant(1),
slot2Participant: participant(2),
urls: { slot1: 'https://cdn.test/slot1.webm', slot2: 'https://cdn.test/slot2.webm' },
ready: true,
error: null,
playing: true,
userPaused: false,
isScrubbing: false,
isResuming: overrides.isResuming ?? false,
playbackEnded: false,
mutedByUser: overrides.mutedByUser,
setMutedByUser: vi.fn(),
playheadSeconds: 5,
timelineSeconds: 60,
turnState: { slot: overrides.turnSlot, seconds: 10, progress: 0.5 },
activeSlot: overrides.turnSlot,
subtitle: null,
onPlaybackTick: vi.fn(),
togglePlayback: vi.fn(),
playFromStart: vi.fn(),
resumeBoth: vi.fn(),
suspend: vi.fn(),
seekBoth: vi.fn(),
beginScrub: vi.fn(),
endScrub: vi.fn(),
};
}

function renderPlayer(overrides: { mutedByUser: boolean; turnSlot: 1 | 2; isResuming?: boolean }) {
mocks.controller = controllerFixture(overrides);
const { container, rerender } = render(
<DebateFeedPlayer debate={{ id: 'debate-1' } as unknown as Debate} active votes={votes} />
);
const [slot1, slot2] = Array.from(container.querySelectorAll('video'));
return {
slot1,
slot2,
/** Re-render with a new controller state, as the hook's own state changes would. */
update(next: { mutedByUser: boolean; turnSlot: 1 | 2; isResuming?: boolean }) {
mocks.controller = controllerFixture(next);
rerender(<DebateFeedPlayer debate={{ id: 'debate-1' } as unknown as Debate} active votes={votes} />);
},
};
}

/**
* Per-turn audio is the `muted` flag: only the debater whose turn it is is audible, and the
* viewer's own mute wins over both. Unchanged from master — an attempt to move this gate onto
* `volume` was reverted, since Blink's effective mute is `muted || volume === 0`, so a listening
* element at volume 0 is exactly as stoppable off screen as a muted one.
*/
describe('DebateFeedPlayer audio gating (GEO-2947)', () => {
beforeEach(() => {
mocks.controller = null;
});

it('leaves only the speaking debater audible once the viewer un-mutes', () => {
const { slot1, slot2 } = renderPlayer({ mutedByUser: false, turnSlot: 1 });

expect(slot1.muted).toBe(false);
expect(slot2.muted).toBe(true);
});

it('moves the mute with the turn', () => {
const { slot1, slot2 } = renderPlayer({ mutedByUser: false, turnSlot: 2 });

expect(slot1.muted).toBe(true);
expect(slot2.muted).toBe(false);
});

it("honours the viewer's mute on both elements", () => {
const { slot1, slot2 } = renderPlayer({ mutedByUser: true, turnSlot: 1 });

expect(slot1.muted).toBe(true);
expect(slot2.muted).toBe(true);
});
});

describe('DebateFeedPlayer repairs a mute made behind React (GEO-2947)', () => {
it('re-asserts the rendered mute once the resume is over', () => {
const { slot1, update } = renderPlayer({ mutedByUser: false, turnSlot: 1, isResuming: true });

// What the fallback does to the speaking element mid-retry, behind React's back.
slot1.muted = true;
expect(slot1.muted).toBe(true);

update({ mutedByUser: false, turnSlot: 1, isResuming: false });

expect(slot1.muted).toBe(false);
});

/** And it must not do it *during* the retry — that would block the play it is waiting on. */
it('leaves the mute alone while the resume is still confirming', () => {
const { slot1, update } = renderPlayer({ mutedByUser: false, turnSlot: 1, isResuming: true });

slot1.muted = true;
// A tick mid-attempt: the turn moves, so the component re-renders and the effect re-runs.
update({ mutedByUser: false, turnSlot: 2, isResuming: true });

expect(slot1.muted).toBe(true);
});

/** A mute the viewer made during the attempt wins — it is the rendered truth by then. */
it('repairs to the newer preference, not the one the attempt started with', () => {
const { slot1, slot2, update } = renderPlayer({ mutedByUser: false, turnSlot: 1, isResuming: true });

slot1.muted = true;
slot2.muted = true;
update({ mutedByUser: true, turnSlot: 1, isResuming: false });

expect(slot1.muted).toBe(true);
expect(slot2.muted).toBe(true);
});
});
33 changes: 32 additions & 1 deletion apps/web/core/debates/browse/debate-feed-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb
playing,
userPaused,
isScrubbing,
isResuming,
playbackEnded,
mutedByUser,
setMutedByUser,
Expand Down Expand Up @@ -108,6 +109,7 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb
countdown={playing && turnState?.slot === 1 ? turnState : null}
subtitle={activeSlot === 1 ? subtitle : null}
mutedByUser={mutedByUser}
isResuming={isResuming}
onPlaybackTick={onPlaybackTick}
onToggle={togglePlayback}
votes={votes}
Expand Down Expand Up @@ -145,6 +147,7 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb
countdown={playing && turnState?.slot === 2 ? turnState : null}
subtitle={activeSlot === 2 ? subtitle : null}
mutedByUser={mutedByUser}
isResuming={isResuming}
onPlaybackTick={onPlaybackTick}
onToggle={togglePlayback}
votes={votes}
Expand Down Expand Up @@ -200,6 +203,7 @@ function DebaterVideo({
countdown,
subtitle,
mutedByUser,
isResuming,
onPlaybackTick,
onToggle,
votes,
Expand All @@ -213,6 +217,7 @@ function DebaterVideo({
countdown: TurnState;
subtitle: string | null;
mutedByUser: boolean;
isResuming: boolean;
onPlaybackTick: () => void;
onToggle: () => void;
votes: DebateVotesResult;
Expand All @@ -222,6 +227,31 @@ function DebaterVideo({
const { openSidePanel } = useEntitySidePanel();
const name = participant ? speakerLabel(participant) : 'Debater';

const muted = !audible || mutedByUser;

/**
* Re-assert the rendered mute after a resume (GEO-2947).
*
* `playBothWithMutedFallback` mutes both elements to retry a blocked play and cannot put them
* back: it does not know what this component renders `muted` from, and anything it captured is
* a confirm window out of date by the time it could write it. React will not repair that either
* — it writes a DOM property only when its *own* previous value differs, and a mute it never
* made is invisible to it — so the element would play audibly under a UI showing muted.
*
* Not while a resume is confirming: the retry depends on the mute it just made, and writing over
* it mid-attempt would block the play this is trying to let happen. `isResuming` falling is
* itself what runs this effect again, so the repair lands the moment the attempt is over.
*
* This is why `playFromStart` no longer writes `muted` either. The value it has (`mutedByUser`)
* is not the value rendered here, so repairing from the hook moved the divergence rather than
* closing it. `muted` has one owner: this render.
*/
React.useLayoutEffect(() => {
const video = videoRef.current;
if (!video || isResuming) return;
video.muted = muted;
}, [isResuming, muted, src, videoRef]);

// A personal space's own id resolves to its "system entity" (an ugly technical
// record). The space's page entity is the real profile, so open that once it's
// loaded and fall back to the space id while it's still fetching.
Expand All @@ -244,7 +274,8 @@ function DebaterVideo({
playsInline
preload="metadata"
src={src}
muted={!audible || mutedByUser}
// The viewer's own mute — plus the listening debater's, where `volume` is a no-op.
muted={muted}
onEnded={onPlaybackTick}
onLoadedMetadata={onPlaybackTick}
onPause={onPlaybackTick}
Expand Down
Loading
Loading