diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 10d7b9935..9ce223c0e 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -643,6 +643,34 @@ safe) and mirrors `onError`'s requirements exactly: pass a **stable** `onPlaying since it too sits in the attach effect's dependency array and an unstable identity would restart the stream every render. +**Autoplay (#554).** `HlsPlayer` takes an optional `muted` prop, **defaulting to `false`**. Muted +media is the one thing browsers autoplay without user activation, so passing it makes the +`video.play()` kick above succeed even when `MANIFEST_PARSED` arrives past the browser's transient +user-activation window (~5s in Chrome) — the failure mode a slow-starting channel (unbounded manifest +`maxTimeToFirstByteMs`, see above) hits, leaving a player sitting at "starting" over a black frame +with no indication the operator just needs to press play. It is a **per-consumer** choice rather than +a player-wide default because muting costs audio-by-default: the channel-preview panel +(`ChannelPreviewPanel.tsx`) opts in — it answers "does this channel work right now", and starting +beats being audible — while the playback-troubleshooting screen +(`PlaybackTroubleshootingScreen.tsx`) must stay unmuted, since verifying the audio side of an FFmpeg +profile is part of its job. `controls` is always on either way, so a muted player can be unmuted by +hand. + +For the residual case where autoplay is rejected anyway (a stricter browser policy or an extension), +`HlsPlayer` also takes an optional `onAutoplayBlocked()`. It fires **only** on an autoplay-policy +rejection — a `DOMException` named `NotAllowedError`; a `play()` promise rejected under any other +name (notably `AbortError`, which is what a pending `play()` interrupted by a teardown produces, and +a consumer's own Retry does exactly that) is not reported, or the caller would put a cause in front +of the operator that did not happen. Like `onError`/`onPlaying` it sits in the attach effect's +dependency array, so pass a `useCallback`; it is purely additive and omitting it is safe. The +channel-preview panel wires it to a "press play" hint whose visibility is the render guard +`state === 'starting'` **alone**, so a second clear in `onPlaying` would be a guard no test could +distinguish. That holds because the panel's paths back to `starting` either clear the flag themselves +(Retry, a channel change) or cannot be reached while it is set — the forced-preview opt-in re-enters +`starting` without clearing, but its button renders only while the panel has not started, and the +only thing that un-starts it is the channel reset that clears the flag. The panel never claims the +fault fixed itself; the hint mirrors an honest, still-not-playing state. + **Resolving a `/iptv/*` src under JWT auth (#552).** Before feeding an `/iptv/*` manifest URL to `HlsPlayer`, pass it through `withIptvToken(url)` (`web/src/media/iptvToken.ts`): under a JWT-enabled deployment it appends the short-lived `?access_token=` the `/iptv/*` scheme requires (the `ctv-session` diff --git a/web/src/media/HlsPlayer.test.tsx b/web/src/media/HlsPlayer.test.tsx index 1337d4841..c8ed7fd16 100644 --- a/web/src/media/HlsPlayer.test.tsx +++ b/web/src/media/HlsPlayer.test.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HlsPlayer } from './HlsPlayer'; const hlsMock = { @@ -105,4 +105,178 @@ describe('HlsPlayer', () => { const video = container.querySelector('video'); expect(() => video?.dispatchEvent(new Event('playing'))).not.toThrow(); }); + + // #554: the panel used to sit at "starting" over a black frame with no hint when the browser + // rejected the manifest-parsed play() kick as blocked autoplay. Muted media autoplays without user + // activation, but muting costs audio-by-default, so it is the caller's decision, not the player's. + it('renders the video element unmuted by default', () => { + const { container } = render(); + + const video = container.querySelector('video'); + expect(video?.muted).toBe(false); + }); + + it('renders the video element muted when the caller opts in', () => { + const { container } = render(); + + const video = container.querySelector('video'); + expect(video?.muted).toBe(true); + }); + + it('reports onAutoplayBlocked when the manifest-parsed play() kick is rejected by autoplay policy', async () => { + const onAutoplayBlocked = vi.fn(); + const playSpy = vi + .spyOn(HTMLMediaElement.prototype, 'play') + .mockRejectedValue(new DOMException('blocked', 'NotAllowedError')); + + try { + render(); + + const manifestHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsManifestParsed')?.[1]; + manifestHandler(); + + // The rejection settles on a microtask; flush it before asserting. + await Promise.resolve(); + await Promise.resolve(); + + expect(onAutoplayBlocked).toHaveBeenCalledTimes(1); + } finally { + playSpy.mockRestore(); + } + }); + + // A play() interrupted by teardown rejects with AbortError — which is what a caller's own Retry + // produces while the manifest-parsed play() is still pending. Reporting that as blocked autoplay + // would put a false cause in front of the operator. + it('does not report onAutoplayBlocked when play() is rejected for another reason', async () => { + const onAutoplayBlocked = vi.fn(); + const playSpy = vi + .spyOn(HTMLMediaElement.prototype, 'play') + .mockRejectedValue(new DOMException('The play() request was interrupted', 'AbortError')); + + try { + render(); + + const manifestHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsManifestParsed')?.[1]; + manifestHandler(); + + await Promise.resolve(); + await Promise.resolve(); + + expect(onAutoplayBlocked).not.toHaveBeenCalled(); + } finally { + playSpy.mockRestore(); + } + }); + + it('does not report onAutoplayBlocked when the play() kick succeeds', async () => { + const onAutoplayBlocked = vi.fn(); + render(); + + const manifestHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsManifestParsed')?.[1]; + manifestHandler(); + + await Promise.resolve(); + await Promise.resolve(); + + expect(onAutoplayBlocked).not.toHaveBeenCalled(); + }); + + it('is safe with no onAutoplayBlocked provided', async () => { + const playSpy = vi + .spyOn(HTMLMediaElement.prototype, 'play') + .mockRejectedValue(new DOMException('blocked', 'NotAllowedError')); + + try { + render(); + + const manifestHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsManifestParsed')?.[1]; + expect(() => manifestHandler()).not.toThrow(); + + await Promise.resolve(); + await Promise.resolve(); + } finally { + playSpy.mockRestore(); + } + }); + + // The native-HLS (Safari) branch carries its own copies of the play() kick and of the playing/error + // wiring; without MediaSource in jsdom nothing else in this file executes it, so it is exercised + // here explicitly rather than assumed to mirror the MSE path. + describe('native HLS (Safari) path', () => { + let canPlayTypeSpy: ReturnType; + + beforeEach(() => { + hlsMock.isSupported = false; + canPlayTypeSpy = vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe'); + }); + + afterEach(() => { + canPlayTypeSpy.mockRestore(); + }); + + it('reports onAutoplayBlocked when the canplay play() kick is rejected by autoplay policy', async () => { + const onAutoplayBlocked = vi.fn(); + const playSpy = vi + .spyOn(HTMLMediaElement.prototype, 'play') + .mockRejectedValue(new DOMException('blocked', 'NotAllowedError')); + + try { + const { container } = render( + + ); + + // Confirm this really is the native branch: hls.js was never constructed. + expect(hlsMock.loadSource).not.toHaveBeenCalled(); + + const video = container.querySelector('video'); + video?.dispatchEvent(new Event('canplay')); + + await Promise.resolve(); + await Promise.resolve(); + + expect(onAutoplayBlocked).toHaveBeenCalledTimes(1); + } finally { + playSpy.mockRestore(); + } + }); + + it('does not report onAutoplayBlocked when the canplay play() kick is rejected for another reason', async () => { + const onAutoplayBlocked = vi.fn(); + const playSpy = vi + .spyOn(HTMLMediaElement.prototype, 'play') + .mockRejectedValue(new DOMException('The play() request was interrupted', 'AbortError')); + + try { + const { container } = render( + + ); + + const video = container.querySelector('video'); + video?.dispatchEvent(new Event('canplay')); + + await Promise.resolve(); + await Promise.resolve(); + + expect(onAutoplayBlocked).not.toHaveBeenCalled(); + } finally { + playSpy.mockRestore(); + } + }); + + it('reports onPlaying and onError from the element\'s own events', () => { + const onError = vi.fn(); + const onPlaying = vi.fn(); + const { container } = render( + + ); + + const video = container.querySelector('video'); + video?.dispatchEvent(new Event('playing')); + expect(onPlaying).toHaveBeenCalledTimes(1); + + video?.dispatchEvent(new Event('error')); + expect(onError).toHaveBeenCalledWith('The browser could not play this stream'); + }); + }); }); diff --git a/web/src/media/HlsPlayer.tsx b/web/src/media/HlsPlayer.tsx index 96c3cdce4..25fdcc874 100644 --- a/web/src/media/HlsPlayer.tsx +++ b/web/src/media/HlsPlayer.tsx @@ -20,6 +20,29 @@ export interface HlsPlayerProps { // before any media has decoded (an HttpLiveStreamingDirect manifest always parses even over a // black video). Optional and purely additive — omitting it is safe. onPlaying?: () => void; + // Called when the browser rejects the player's own `video.play()` kick under its autoplay policy + // — and only then: the promise is rejected with a `NotAllowedError` DOMException in that case, and + // with a different name (e.g. `AbortError`) when a pending play() is merely interrupted by + // teardown, which must not be reported as blocked autoplay or the caller names a cause that did + // not happen. Passing `muted` lets Chromium/Firefox autoplay without user activation in the common + // case (#554), but a stricter policy (an extension, or a browser configured to block all autoplay) + // can still reject it — this is the fallback so a caller can tell the operator to press play rather + // than let the panel sit at "starting" with no explanation. Optional and purely additive. + onAutoplayBlocked?: () => void; + // Render the element muted. Off by default: the playback-troubleshooting screen exists partly to + // verify the audio side of an FFmpeg profile, so it must stay audible. The channel preview opts in + // (#554) because browsers permit autoplay of muted media without user activation, which is what + // keeps a slow-starting preview from sitting at "starting" over a black frame. `controls` is always + // on, so a muted player can still be unmuted by hand. + muted?: boolean; +} + +// A rejected video.play() says why in the DOMException's name. Only NotAllowedError is the autoplay +// policy refusing to start unprompted; AbortError (a pending play() interrupted by a teardown — what +// the preview panel's own Retry does while MANIFEST_PARSED's play() is still pending) and +// NotSupportedError are different faults and must not be reported as blocked autoplay. +function isAutoplayPolicyRejection(error: unknown): boolean { + return error instanceof DOMException && error.name === 'NotAllowedError'; } // A small HLS video player. Uses hls.js when Media Source Extensions are available (config mirrors @@ -27,7 +50,16 @@ export interface HlsPlayerProps { // segments exist, so the manifest request must tolerate an unbounded time-to-first-byte), and falls // back to the browser's native HLS support (Safari) otherwise. Reusable across any screen that needs // to preview an ErsatzTV HLS stream. -export function HlsPlayer({ src, playToken = 0, className, style, onError, onPlaying }: HlsPlayerProps) { +export function HlsPlayer({ + src, + playToken = 0, + className, + style, + onError, + onPlaying, + onAutoplayBlocked, + muted = false +}: HlsPlayerProps) { const videoRef = useRef(null); const hlsRef = useRef(null); @@ -63,8 +95,11 @@ export function HlsPlayer({ src, playToken = 0, className, style, onError, onPla hls.loadSource(src); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => { - void video.play().catch(() => { - // Autoplay may be blocked; the user can press play manually. + void video.play().catch((error: unknown) => { + // Autoplay can be blocked even when muted (#554); the user can press play manually. + if (isAutoplayPolicyRejection(error)) { + onAutoplayBlocked?.(); + } }); }); hls.on(Hls.Events.ERROR, (_event, data) => { @@ -86,8 +121,11 @@ export function HlsPlayer({ src, playToken = 0, className, style, onError, onPla if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src; const onCanPlay = () => { - void video.play().catch(() => { - // Autoplay may be blocked; the user can press play manually. + void video.play().catch((error: unknown) => { + // Autoplay can be blocked even when muted (#554); the user can press play manually. + if (isAutoplayPolicyRejection(error)) { + onAutoplayBlocked?.(); + } }); }; video.addEventListener('canplay', onCanPlay); @@ -110,7 +148,7 @@ export function HlsPlayer({ src, playToken = 0, className, style, onError, onPla return undefined; // playToken forces re-attachment for repeat plays of an identical src (see HlsPlayerProps). - }, [src, playToken, onError, onPlaying]); + }, [src, playToken, onError, onPlaying, onAutoplayBlocked]); // Final unmount safety net (covers the hls.js instance in every path). useEffect( @@ -120,5 +158,7 @@ export function HlsPlayer({ src, playToken = 0, className, style, onError, onPla [] ); - return