Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions apps/web/app/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import dynamic from 'next/dynamic';

import { DebateCoordinator } from '~/core/debates/debate-coordinator';
import { DebateMediaSessionProvider } from '~/core/debates/media-session';
import { PlaybackDiagnostics } from '~/core/debates/playback-diagnostics';
import { DebateRecordingUploadCoordinator } from '~/core/debates/recording-upload-coordinator';
import { useGeoLogoutCleanup } from '~/core/hooks/use-geo-logout';
import { useKeyboardShortcuts } from '~/core/hooks/use-keyboard-shortcuts';
Expand Down Expand Up @@ -137,6 +138,7 @@ export function App({ children }: { children: React.ReactNode }) {
</div>
<SlideUpBodyState />
<EntitySidePanel />
<PlaybackDiagnostics />
<EntityCommentsPanelHost />
{/* Client-side rendered due to `window.localStorage` usage */}
<ClientOnly>
Expand Down
54 changes: 51 additions & 3 deletions apps/web/core/debates/browse/debate-feed-player.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ const votes: DebateVotesResult = {
* 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 }) {
function controllerFixture(overrides: {
mutedByUser: boolean;
turnSlot: 1 | 2;
isResuming?: boolean;
/** The browser refused to autoplay — see `useDebatePlayback`. */
autoplayBlocked?: boolean;
playing?: boolean;
}) {
return {
slot1VideoRef: { current: null },
slot2VideoRef: { current: null },
Expand All @@ -53,7 +60,8 @@ function controllerFixture(overrides: { mutedByUser: boolean; turnSlot: 1 | 2; i
urls: { slot1: 'https://cdn.test/slot1.webm', slot2: 'https://cdn.test/slot2.webm' },
ready: true,
error: null,
playing: true,
playing: overrides.playing ?? true,
autoplayBlocked: overrides.autoplayBlocked ?? false,
userPaused: false,
isScrubbing: false,
isResuming: overrides.isResuming ?? false,
Expand All @@ -77,7 +85,13 @@ function controllerFixture(overrides: { mutedByUser: boolean; turnSlot: 1 | 2; i
}

function renderPlayer(
overrides: { mutedByUser: boolean; turnSlot: 1 | 2; isResuming?: boolean },
overrides: {
mutedByUser: boolean;
turnSlot: 1 | 2;
isResuming?: boolean;
autoplayBlocked?: boolean;
playing?: boolean;
},
reactStrictMode = false
) {
mocks.controller = controllerFixture(overrides);
Expand Down Expand Up @@ -196,3 +210,37 @@ describe('DebateFeedPlayer media release (GEO-2963)', () => {
expect(slot2.hasAttribute('src')).toBe(false);
});
});

/**
* A refused autoplay has to reach the screen (GEO-2978).
*
* Three fixes went in without the control ever appearing, and every attempt to
* check it went through a browser — which measured the hero carousel at the top
* of Explore rather than a debate card, twice. This asks the component
* directly: given a controller that says the browser refused, is there
* something to tap?
*/
describe('a refused autoplay', () => {
it('shows the play control', () => {
const { container } = (() => {
mocks.controller = controllerFixture({
mutedByUser: true,
turnSlot: 1,
autoplayBlocked: true,
playing: false,
});
return render(<DebateFeedPlayer debate={{ id: 'debate-1' } as unknown as Debate} active votes={votes} />);
})();

expect(container.querySelector('[aria-label="Resume debate"]')).not.toBeNull();
});

it('shows nothing extra while playback is running normally', () => {
mocks.controller = controllerFixture({ mutedByUser: true, turnSlot: 1 });
const { container } = render(
<DebateFeedPlayer debate={{ id: 'debate-1' } as unknown as Debate} active votes={votes} />
);

expect(container.querySelector('[aria-label="Resume debate"]')).toBeNull();
});
});
41 changes: 35 additions & 6 deletions 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
error,
playing,
userPaused,
autoplayBlocked,
isScrubbing,
isResuming,
playbackEnded,
Expand Down Expand Up @@ -83,22 +84,33 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb
seekBothRaw(seconds);
};

/**
* Stopped, and only a tap will start it.
*
* The two ways in are different facts — the viewer paused, or the browser
* refused — and identical from here: the video is not running and the control
* is the only answer either accepts.
*/
const awaitingTap = userPaused || autoplayBlocked;

// Autoplay the debate that's in view; pause the rest. Respect an explicit
// user pause so scrolling back doesn't fight the viewer, and don't resume
// mid-scrub.
React.useEffect(() => {
if (!ready) return;
if (active && !userPaused && !isScrubbing && !playing && !playbackEnded) {
// A refusal is not retried: the browser gives the same answer every time, and
// only the viewer's tap is a gesture it will accept.
if (active && !awaitingTap && !isScrubbing && !playing && !playbackEnded) {
void resumeBoth();
} else if (!active && playing) {
suspend();
}
}, [active, isScrubbing, playbackEnded, playing, ready, resumeBoth, suspend, userPaused]);
}, [active, autoplayBlocked, isScrubbing, playbackEnded, playing, ready, resumeBoth, suspend, userPaused]);

const showControls = ready && (userPaused || (playbackEnded && !hasVoted));
// End of an unvoted debate offers a replay; a user pause shows the paused glyph.
const showControls = ready && (awaitingTap || (playbackEnded && !hasVoted));
// End of an unvoted debate offers a replay; a stopped one shows the paused glyph.
const showReplay = ready && playbackEnded && !hasVoted;
const showPausedGlyph = ready && userPaused && !playbackEnded;
const showPausedGlyph = ready && awaitingTap && !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 }>({
Expand All @@ -120,7 +132,24 @@ export function DebateFeedPlayer({ debate, active, preload = false, votes }: Deb
};

return (
<div ref={measurement.elementRef} className="group relative flex flex-col gap-2">
<div
ref={measurement.elementRef}
/*
* The player's state, readable from outside React.
*
* Autoplay faults here are device-specific — iOS refuses in Low Power Mode
* and headless engines do not — so the machine that reproduces them is
* rarely one with a debugger attached. These four booleans are what
* `PlaybackDiagnostics` reports, and what an inspector on a phone can read
* without one. They are the difference between "the browser refused" and
* "the app never noticed", which look identical on screen.
*/
data-debate-ready={ready ? 'true' : 'false'}
data-debate-active={active ? 'true' : 'false'}
data-debate-playing={playing ? 'true' : 'false'}
data-debate-autoplay-blocked={autoplayBlocked ? 'true' : 'false'}
className="group relative flex flex-col gap-2"
>
<DebaterVideo
participant={slot1Participant}
src={urls.slot1}
Expand Down
42 changes: 42 additions & 0 deletions apps/web/core/debates/playback-diagnostics-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* DOM stand-ins for what `DebateFeedPlayer` puts on the page, shared by the diagnostic's two
* suites — the readout's own, and the one that needs a fresh module to check install ordering.
*/

/**
* jsdom lays nothing out, so every element measures 0×0 and would read as off screen. Sizes are
* assigned per element instead, which is also the only way to place one deliberately out of view.
*/
export function placeAt(element: Element, box: { top: number; left: number; width: number; height: number }) {
element.getBoundingClientRect = () =>
({
top: box.top,
left: box.left,
right: box.left + box.width,
bottom: box.top + box.height,
width: box.width,
height: box.height,
x: box.left,
y: box.top,
toJSON: () => ({}),
}) as DOMRect;
}

export const ON_SCREEN = { top: 10, left: 0, width: 300, height: 200 };
export const OFF_TO_THE_SIDE = { top: 10, left: 5_000, width: 300, height: 200 };

/** A debate card as `DebateFeedPlayer` publishes it: the state attributes, with its videos inside. */
export function debateCard(state: { playing: boolean; blocked: boolean }) {
const card = document.createElement('div');
card.setAttribute('data-debate-ready', 'true');
card.setAttribute('data-debate-active', 'true');
card.setAttribute('data-debate-playing', String(state.playing));
card.setAttribute('data-debate-autoplay-blocked', String(state.blocked));
placeAt(card, ON_SCREEN);

const video = document.createElement('video');
placeAt(video, ON_SCREEN);
card.append(video);

return { card, video };
}
82 changes: 82 additions & 0 deletions apps/web/core/debates/playback-diagnostics-install.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';

import * as React from 'react';

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

import { featureFlagsStorageKey } from '~/core/state/feature-flags';

import { PlaybackDiagnostics } from './playback-diagnostics';
import { debateCard } from './playback-diagnostics-fixtures';

/*
* Its own file, and that is the point rather than an accident of organisation.
*
* `patchMediaElement` installs once per module instance and never uninstalls, so any earlier test
* in the same file leaves the prototype already patched — and a test for *when* the patch arrives
* would then pass without it. Vitest gives each file a fresh module, which is the only thing that
* makes this assertion mean anything.
*/
vi.mock('~/core/state/feature-flags', async importOriginal => ({
...(await importOriginal<typeof import('~/core/state/feature-flags')>()),
usePlaybackDiagnosticsEnabled: () => true,
}));

const originalPlay = HTMLMediaElement.prototype.play;

beforeEach(() => {
window.innerWidth = 400;
window.innerHeight = 800;
// The install reads the persisted flag directly rather than through the hydration-gated hook,
// because that hook is false for the first commit by design.
window.localStorage.setItem(featureFlagsStorageKey, JSON.stringify({ playbackDiagnostics: true }));
HTMLMediaElement.prototype.play = () =>
Promise.reject(new DOMException('not allowed by the user agent', 'NotAllowedError'));
});

afterEach(() => {
cleanup();
HTMLMediaElement.prototype.play = originalPlay;
window.localStorage.removeItem(featureFlagsStorageKey);
document.querySelectorAll('[data-debate-ready], video').forEach(node => node.remove());
});

describe('PlaybackDiagnostics — the trace is installed before the effects it observes', () => {
/**
* The probe has to be in place before the playback it watches, or it reports a negative that
* cannot be told apart from a real one.
*
* `usePlaybackDiagnosticsEnabled` is hydration-gated and returns `false` for the first commit by
* design, so patching on it left the trace to a passive effect racing `DebateFeedPlayer`'s own.
* Lose that race and the readout says `calls=[]` — which does not mean "not recorded", it means
* "nothing ever asked this to play": a different fault, in a different part of the code, and
* exactly the wrong signpost this file exists to remove.
*
* A sibling's passive effect stands in for the card's, mounted *ahead* of the diagnostic so that
* a passive install loses. Layout effects run during commit, before every passive effect in the
* tree, which is what makes the ordering a guarantee rather than a race this usually wins.
*/
it('records a play() from a passive effect that mounts ahead of it', async () => {
const { card, video } = debateCard({ playing: false, blocked: false });
document.body.append(card);

function PlaysOnMount() {
React.useEffect(() => {
void video.play().catch(() => {});
}, []);
return null;
}

render(
<>
<PlaysOnMount />
<PlaybackDiagnostics />
</>
);

await waitFor(() => expect(screen.getByText(/calls=\[play REJECTED:NotAllowedError/)).toBeInTheDocument(), {
timeout: 3_000,
});
});
});
Loading
Loading