From 8ad554b40fd482fb3c364ddab92edaf4c4d2fb77 Mon Sep 17 00:00:00 2001 From: Christopher Klint Date: Tue, 6 Oct 2026 20:31:42 +0200 Subject: [PATCH] fix(live): authorize native compatibility HLS from first play --- server/LIVE_PACKET.md | 2 +- src/hooks/usePlayer.integration.test.tsx | 50 ++++++- src/hooks/usePlayer.liveHls.test.tsx | 165 ++++++++++++++++------- src/hooks/usePlayer.ts | 48 +++---- 4 files changed, 182 insertions(+), 83 deletions(-) diff --git a/server/LIVE_PACKET.md b/server/LIVE_PACKET.md index 6af6347..67d9d6c 100644 --- a/server/LIVE_PACKET.md +++ b/server/LIVE_PACKET.md @@ -10,4 +10,4 @@ The runtime image installs Debian `python3` and `python3-av`. Node invokes `/usr Local integration tests exercise an authenticated loopback proxy, signed playlist **and** segment fetches, both clean EOF framings, an exact non-IDR continuation checked against a baseline packet inventory, and strict FFmpeg video/audio HLS decode. Negative cases include shifted AAC, a missing replay picture, incompatible video, false Content-Length, and an open silent source; previously cached segments are invalidated and the channel does not thrash-restart. Admission, reader cap, and shutdown cleanup are separately exercised. The original standalone matcher tests include ambiguous replay and bounded probing. A production-sized, natural-EOF fixture also exercises a fast two-session HLS manifest rollover on the Pi with the default poll interval and quotas. -**Acceptance scope:** CI builds and runs the actual x64 and ARM64 runtime images with Debian PyAV; x64 also runs the packaged server suite. Two natural live-proxy EOF responses (~84 MB each) were locally replayed across one non-IDR seam with strict HLS decode and once-only H.264/AAC boundary hashes. This does not prove sustained Pi throughput under concurrent viewers, replay catch-up against a physical player's buffered lead, or physical Samsung/iPhone playback. Native Apple/WebKit clients request `/api/live-compatible` if the strict primary presentation is unavailable or fails. This independently signed, four-channel-capped FFmpeg stream-copy HLS buffer uses a sibling cache and new segment epochs, publishing explicit discontinuities rather than making a seamless-stitch claim. Primary unsafe latches and stale media remain invalid, and authorization rejection never downgrades to compatibility. Unavailable compatibility ends with an actionable error, not native raw MPEG-TS or an endless spinner. Audio-only retains its existing path. A channel whose seam cannot be verified is never spliced with unproved media. +**Acceptance scope:** CI builds and runs the actual x64 and ARM64 runtime images with Debian PyAV; x64 also runs the packaged server suite. Two natural live-proxy EOF responses (~84 MB each) were locally replayed across one non-IDR seam with strict HLS decode and once-only H.264/AAC boundary hashes. This does not prove sustained Pi throughput under concurrent viewers, replay catch-up against a physical player's buffered lead, or physical Samsung/iPhone playback. Native Apple/WebKit clients request `/api/live-compatible` directly from the first play, including reconnects and explicit retry. They do not wait through a separate primary authorization/startup cycle; desktop/MSE and AVPlay retain the primary path. This independently signed, four-channel-capped FFmpeg stream-copy HLS buffer uses a sibling cache and new segment epochs, publishing explicit discontinuities rather than making a seamless-stitch claim. Primary unsafe latches and stale media remain invalid, and authorization rejection never downgrades to compatibility. Unavailable compatibility ends with an actionable error, not native raw MPEG-TS or an endless spinner. Audio-only retains its existing path. A channel whose seam cannot be verified is never spliced with unproved media. diff --git a/src/hooks/usePlayer.integration.test.tsx b/src/hooks/usePlayer.integration.test.tsx index 76d772c..68a8fdd 100644 --- a/src/hooks/usePlayer.integration.test.tsx +++ b/src/hooks/usePlayer.integration.test.tsx @@ -224,6 +224,9 @@ describe('usePlayer manual seek integration', () => { }); it('opens the same buffered live HLS feed on Samsung instead of a per-view TS response', async () => { + // AVPlay remains primary even if the embedded UA advertises native HLS. + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); + vi.spyOn(video, 'canPlayType').mockReturnValue('maybe'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => String(url).endsWith('/authorize') ? new Response(JSON.stringify({ playlistUrl: '/api/live/live_future/index.m3u8?ticket=synthetic' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) : new Response('{}', { status: 404 })); @@ -238,12 +241,14 @@ describe('usePlayer manual seek integration', () => { (globalThis as typeof globalThis & { webapis: WebApis }).webapis = { avplay } as unknown as WebApis; await act(async () => hookRef.current?.play()); await vi.waitFor(() => expect(avplay.open).toHaveBeenCalledWith('http://localhost:3000/api/live/live_future/index.m3u8?ticket=synthetic')); + expect(vi.mocked(fetch).mock.calls.filter(([url]) => String(url).endsWith('/authorize')).map(([url]) => url)) + .toEqual(['/api/live/live_future/authorize']); await act(async () => hookRef.current?.stop()); }); - it('uses native buffered live HLS on iPhone instead of a per-view mpegts player', async () => { + it('uses compatible native live HLS directly on iPhone instead of primary or a per-view mpegts player', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => String(url).endsWith('/authorize') - ? new Response(JSON.stringify({ playlistUrl: '/api/live/live_future/index.m3u8?ticket=synthetic' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + ? new Response(JSON.stringify({ playlistUrl: '/api/live-compatible/live_future/index.m3u8?ticket=synthetic' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) : new Response('{}', { status: 404 })); vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15'); vi.spyOn(video, 'canPlayType').mockReturnValue('maybe'); @@ -252,11 +257,48 @@ describe('usePlayer manual seek integration', () => { logo: '', group: '', region: '', contentType: 'livetv', } }); await act(async () => { hookRef.current?.play(); await Promise.resolve(); }); - await vi.waitFor(() => expect(video.src).toBe('http://localhost:3000/api/live/live_future/index.m3u8?ticket=synthetic')); + await vi.waitFor(() => expect(video.src).toBe('http://localhost:3000/api/live-compatible/live_future/index.m3u8?ticket=synthetic')); + expect(vi.mocked(fetch).mock.calls.filter(([url]) => String(url).endsWith('/authorize')).map(([url]) => url)) + .toEqual(['/api/live-compatible/live_future/authorize']); expect(mpegtsMock.createPlayer).not.toHaveBeenCalled(); await act(async () => hookRef.current?.stop()); }); + it.each(['pending first response', 'pending response after 503'])( + 'hard-aborts native compatible authorization at the total 50-second deadline with %s', async failure => { + vi.useFakeTimers(); + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15'); + vi.spyOn(video, 'canPlayType').mockReturnValue('maybe'); + usePlayerStore.setState({ currentChannel: { + id: 'live_future', name: 'Live channel', url: '/unused', logo: '', group: '', region: '', contentType: 'livetv', + }, audioOnly: false }); + let signal: AbortSignal | null | undefined; + let requests = 0; + vi.mocked(fetch).mockImplementation(async (url, options) => { + if (!String(url).endsWith('/authorize')) return new Response('{}', { status: 404 }); + requests++; + if (failure === 'pending response after 503' && requests === 1) { + return new Response('{}', { status: 503, headers: { 'Content-Type': 'application/json', 'Retry-After': '2' } }); + } + signal = options?.signal; + return new Promise(() => {}); + }); + try { + await act(async () => { hookRef.current?.play(); await vi.advanceTimersByTimeAsync(49_999); }); + expect(usePlayerStore.getState().status).toBe('loading'); + expect(signal?.aborted).toBe(false); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(signal?.aborted).toBe(true); + expect(usePlayerStore.getState().status).toBe('error'); + await act(async () => vi.advanceTimersByTimeAsync(65_000)); + expect(vi.mocked(fetch).mock.calls.filter(([url]) => String(url).endsWith('/authorize')).map(([url]) => url)) + .toEqual(Array(failure === 'pending response after 503' ? 2 : 1).fill('/api/live-compatible/live_future/authorize')); + expect(mpegtsMock.createPlayer).not.toHaveBeenCalled(); + expect(video.getAttribute('src')).toBeNull(); + } finally { await act(async () => hookRef.current?.stop()); } + }, + ); + it('keeps one HLS.js live player on desktop while the source reconnects behind the playlist', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => String(url).endsWith('/authorize') ? new Response(JSON.stringify({ playlistUrl: '/api/live/live_future/index.m3u8?ticket=synthetic' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) @@ -270,6 +312,8 @@ describe('usePlayer manual seek integration', () => { await act(async () => { hookRef.current?.play(); await Promise.resolve(); }); await vi.waitFor(() => expect(hlsMock.instances).toHaveLength(1)); expect(hlsMock.instances[0].loadSource).toHaveBeenCalledWith('/api/live/live_future/index.m3u8?ticket=synthetic'); + expect(vi.mocked(fetch).mock.calls.filter(([url]) => String(url).endsWith('/authorize')).map(([url]) => url)) + .toEqual(['/api/live/live_future/authorize']); expect(mpegtsMock.createPlayer).not.toHaveBeenCalled(); await act(async () => hookRef.current?.stop()); expect(hlsMock.instances[0].destroy).toHaveBeenCalledOnce(); diff --git a/src/hooks/usePlayer.liveHls.test.tsx b/src/hooks/usePlayer.liveHls.test.tsx index 4f36000..222c504 100644 --- a/src/hooks/usePlayer.liveHls.test.tsx +++ b/src/hooks/usePlayer.liveHls.test.tsx @@ -5,6 +5,7 @@ import { usePlayer } from './usePlayer'; import { usePlayerStore } from '../stores/playerStore'; import type { getAuthorizedLiveHlsUrl } from '../services/livePlayback'; import { ApiError, rotateBackendRequestScope } from '../services/api'; +import { clientLogger } from '../utils/logger'; const mocks = vi.hoisted(() => { // The module-level recovery singleton captures Date.now in its constructor. @@ -44,7 +45,7 @@ const Harness = forwardRef>(function Harness(_props return null; }); -describe('signed live MSE startup lead', () => { +describe('signed live HLS startup and recovery', () => { let root: Root; let container: HTMLDivElement; let video: HTMLVideoElement; @@ -57,7 +58,9 @@ describe('signed live MSE startup lead', () => { mocks.instances.length = 0; mocks.hlsSupported.mockReturnValue(true); mocks.authorize.mockReset(); - mocks.authorize.mockResolvedValue('/api/live/live_test/index.m3u8?ticket=test-only'); + mocks.authorize.mockImplementation(async (_base, _channel, _origin, options) => options?.delivery === 'compatible' + ? '/api/live-compatible/live_test/index.m3u8?ticket=compatible-test' + : '/api/live/live_test/index.m3u8?ticket=test-only'); localStorage.clear(); vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 404 })); video = document.createElement('video'); @@ -157,14 +160,61 @@ describe('signed live MSE startup lead', () => { }, ); - it.each(['iPhone', 'Safari'])('uses compatible signed native HLS after primary unavailability on %s', async platform => { + it.each(['iPhone', 'Safari'])('requests compatible HLS immediately on %s without waiting for or racing primary', async platform => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform === 'iPhone' ? 'iPhone AppleWebKit/605.1.15 Safari/604.1' : 'Macintosh AppleWebKit/605.1.15 Safari/605.1.15'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); - mocks.authorize.mockResolvedValueOnce(null) - .mockResolvedValueOnce('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); + let resolveAuthorization!: (url: string) => void; + mocks.authorize.mockImplementationOnce(() => new Promise(resolve => { resolveAuthorization = resolve; })); + await act(async () => hookRef.current?.play()); + expect(mocks.authorize).toHaveBeenCalledOnce(); + expect(mocks.authorize).toHaveBeenNthCalledWith(1, expect.any(String), 'live_test', window.location.origin, + expect.objectContaining({ delivery: 'compatible', isCurrent: expect.any(Function) })); + await act(async () => vi.advanceTimersByTimeAsync(49_000)); + expect(mocks.authorize).toHaveBeenCalledOnce(); + expect(video.getAttribute('src')).toBeNull(); + await act(async () => resolveAuthorization('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test')); + expect(new URL(video.src).pathname).toBe('/api/live-compatible/live_test/index.m3u8'); + expect(mocks.instances).toHaveLength(0); + expect(mocks.mpegtsPlayer.load).not.toHaveBeenCalled(); + }); + + it('logs credential-safe native authorization and only proven first-media latency', async () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); + vi.mocked(video.canPlayType).mockReturnValue('maybe'); + mocks.authorize.mockImplementationOnce(() => new Promise(resolve => setTimeout(() => + resolve('/api/live-compatible/live_test/index.m3u8?ticket=private-test-ticket'), 1200))); + await act(async () => hookRef.current?.play()); + await act(async () => vi.advanceTimersByTimeAsync(1200)); + expect(clientLogger.info).toHaveBeenCalledWith( + 'Native live HLS authorize channelID=live_test delivery=compatible elapsedms=1200'); + await act(async () => { + video.dispatchEvent(new Event('loadeddata')); + video.dispatchEvent(new Event('canplay')); + video.dispatchEvent(new Event('playing')); + video.dispatchEvent(new Event('timeupdate')); + }); + expect(vi.mocked(clientLogger.info).mock.calls.filter(([message]) => message.startsWith('Native live HLS first media'))).toHaveLength(0); + await act(async () => vi.advanceTimersByTimeAsync(300)); + video.currentTime = 1; + await act(async () => video.dispatchEvent(new Event('timeupdate'))); + video.currentTime = 2; + await act(async () => video.dispatchEvent(new Event('timeupdate'))); + const diagnostics = vi.mocked(clientLogger.info).mock.calls.filter(([message]) => message.startsWith('Native live HLS')); + expect(diagnostics).toEqual([ + ['Native live HLS authorize channelID=live_test delivery=compatible elapsedms=1200'], + ['Native live HLS first media channelID=live_test delivery=compatible elapsedms=1500'], + ]); + expect(JSON.stringify(diagnostics)).not.toMatch(/ticket|https?:|\/api\/|Test live|unused/); + }); + + it.each(['iPhone', 'Safari'])('plays the first compatible signed native HLS authorization on %s', async platform => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(platform === 'iPhone' + ? 'iPhone AppleWebKit/605.1.15 Safari/604.1' : 'Macintosh AppleWebKit/605.1.15 Safari/605.1.15'); + vi.mocked(video.canPlayType).mockReturnValue('maybe'); + mocks.authorize.mockResolvedValueOnce('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); await ready(); - expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize).toHaveBeenCalledOnce(); expect(mocks.authorize).toHaveBeenLastCalledWith(expect.any(String), 'live_test', window.location.origin, expect.objectContaining({ delivery: 'compatible', isCurrent: expect.any(Function) })); expect(new URL(video.src).pathname).toBe('/api/live-compatible/live_test/index.m3u8'); @@ -173,13 +223,14 @@ describe('signed live MSE startup lead', () => { expect(mocks.instances).toHaveLength(0); }); - it('uses compatible HLS on a normal replacement play after native primary failure', async () => { + it('keeps explicit retry compatible after native media failure', async () => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); - await ready(); - await act(async () => video.dispatchEvent(new Event('error'))); mocks.authorize.mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); await ready(); + await act(async () => video.dispatchEvent(new Event('error'))); + expect(usePlayerStore.getState().status).toBe('error'); + await act(async () => hookRef.current?.retry()); expect(mocks.authorize).toHaveBeenCalledTimes(2); expect(mocks.authorize).toHaveBeenLastCalledWith(expect.any(String), 'live_test', window.location.origin, expect.objectContaining({ delivery: 'compatible' })); @@ -187,27 +238,41 @@ describe('signed live MSE startup lead', () => { expect(mocks.mpegtsPlayer.load).not.toHaveBeenCalled(); }); - it.each(['primary unavailable', 'primary watchdog'])( - 'stops compatible startup without verified media progress after %s', async primaryFailure => { + it('selects compatible HLS again after remount without losing healthy background playback', async () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); + vi.mocked(video.canPlayType).mockReturnValue('maybe'); + mocks.authorize.mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); + await ready(); + await act(async () => video.dispatchEvent(new Event('timeupdate'))); + const src = video.getAttribute('src'); + const pauses = vi.mocked(video.pause).mock.calls.length; + await act(async () => root.render(null)); + await act(async () => root.render()); + expect(usePlayerStore.getState().status).toBe('playing'); + expect(video.getAttribute('src')).toBe(src); + expect(video.pause).toHaveBeenCalledTimes(pauses); + expect(mocks.authorize).toHaveBeenCalledOnce(); + await act(async () => hookRef.current?.retry()); + expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize.mock.calls.every(call => call[3]?.delivery === 'compatible')).toBe(true); + expect(mocks.mpegtsPlayer.load).not.toHaveBeenCalled(); + }); + + it.each(['zero timeupdate', 'no media events'])( + 'stops first compatible startup without verified media progress with %s', async startup => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); ranges = []; - if (primaryFailure === 'primary unavailable') mocks.authorize.mockResolvedValueOnce(null); - else mocks.authorize.mockResolvedValueOnce('/api/live/live_test/index.m3u8?ticket=test-only'); mocks.authorize.mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); await act(async () => hookRef.current?.play()); - if (primaryFailure === 'primary watchdog') { - await act(async () => vi.advanceTimersByTimeAsync(30_250)); - } - expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize).toHaveBeenCalledOnce(); expect(new URL(video.src).pathname).toBe('/api/live-compatible/live_test/index.m3u8'); // Neither a zero-time clock event nor ready/play events prove media progress. video.currentTime = 0; - await act(async () => video.dispatchEvent(new Event('timeupdate'))); + if (startup === 'zero timeupdate') await act(async () => video.dispatchEvent(new Event('timeupdate'))); await act(async () => vi.advanceTimersByTimeAsync(29_999)); expect(usePlayerStore.getState().status).toBe('loading'); - // Recovery preserves the primary failure's backoff (1s versus initial 250ms). - await act(async () => vi.advanceTimersByTimeAsync(primaryFailure === 'primary watchdog' ? 1_001 : 251)); + await act(async () => vi.advanceTimersByTimeAsync(251)); expect(usePlayerStore.getState().status).toBe('error'); expect(usePlayerStore.getState().errorMessage).toMatch(/compatible.*connection.*retry/i); await act(async () => { @@ -218,7 +283,7 @@ describe('signed live MSE startup lead', () => { await vi.advanceTimersByTimeAsync(169_250); }); expect(usePlayerStore.getState().status).toBe('error'); - expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize).toHaveBeenCalledOnce(); expect(mocks.mpegtsPlayer.load).not.toHaveBeenCalled(); expect(mocks.instances).toHaveLength(0); // Explicit retry starts a fresh attempt and can still become healthy. @@ -229,7 +294,8 @@ describe('signed live MSE startup lead', () => { video.dispatchEvent(new Event('canplay')); video.dispatchEvent(new Event('timeupdate')); }); - expect(mocks.authorize).toHaveBeenCalledTimes(3); + expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize.mock.calls.every(call => call[3]?.delivery === 'compatible')).toBe(true); expect(usePlayerStore.getState().status).toBe('playing'); }, ); @@ -237,27 +303,25 @@ describe('signed live MSE startup lead', () => { it('does not treat compatible readiness or a resolved play promise as media progress', async () => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); - mocks.authorize.mockResolvedValueOnce(null) - .mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); + mocks.authorize.mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); await ready(); expect(usePlayerStore.getState().status).toBe('playing'); await act(async () => vi.advanceTimersByTimeAsync(30_250)); expect(usePlayerStore.getState().status).toBe('error'); expect(usePlayerStore.getState().errorMessage).toMatch(/compatible.*retry/i); await act(async () => vi.advanceTimersByTimeAsync(65_000)); - expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize).toHaveBeenCalledOnce(); expect(mocks.mpegtsPlayer.load).not.toHaveBeenCalled(); }); it('recovers later interruptions after compatible native media genuinely advances', async () => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); - mocks.authorize.mockResolvedValueOnce(null) - .mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); + mocks.authorize.mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); await ready(); await act(async () => video.dispatchEvent(new Event('timeupdate'))); await act(async () => vi.advanceTimersByTimeAsync(12_250)); - expect(mocks.authorize).toHaveBeenCalledTimes(3); + expect(mocks.authorize).toHaveBeenCalledTimes(2); expect(mocks.authorize).toHaveBeenLastCalledWith(expect.any(String), 'live_test', window.location.origin, expect.objectContaining({ delivery: 'compatible' })); expect(usePlayerStore.getState().status).toBe('loading'); @@ -268,7 +332,8 @@ describe('signed live MSE startup lead', () => { video.dispatchEvent(new Event('timeupdate')); await vi.advanceTimersByTimeAsync(12_250); }); - expect(mocks.authorize).toHaveBeenCalledTimes(4); + expect(mocks.authorize).toHaveBeenCalledTimes(3); + expect(mocks.authorize.mock.calls.every(call => call[3]?.delivery === 'compatible')).toBe(true); expect(mocks.mpegtsPlayer.load).not.toHaveBeenCalled(); expect(mocks.instances).toHaveLength(0); }); @@ -276,8 +341,7 @@ describe('signed live MSE startup lead', () => { it('retains playing status when a compatible recovery watchdog fires after backend invalidation', async () => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); - mocks.authorize.mockResolvedValueOnce(null) - .mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); + mocks.authorize.mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); await ready(); expect(usePlayerStore.getState().status).toBe('playing'); const src = video.getAttribute('src'); @@ -286,7 +350,7 @@ describe('signed live MSE startup lead', () => { await act(async () => vi.advanceTimersByTimeAsync(65_000)); expect(usePlayerStore.getState().status).toBe('playing'); expect(usePlayerStore.getState().errorMessage).toBe(''); - expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize).toHaveBeenCalledOnce(); expect(video.play).toHaveBeenCalledOnce(); expect(video.pause).toHaveBeenCalledTimes(pauses); expect(video.getAttribute('src')).toBe(src); @@ -297,8 +361,7 @@ describe('signed live MSE startup lead', () => { it('retains playing status when a compatible native media error fires after backend invalidation', async () => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); - mocks.authorize.mockResolvedValueOnce(null) - .mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); + mocks.authorize.mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); await ready(); expect(usePlayerStore.getState().status).toBe('playing'); const src = video.getAttribute('src'); @@ -310,7 +373,7 @@ describe('signed live MSE startup lead', () => { await act(async () => vi.advanceTimersByTimeAsync(65_000)); expect(usePlayerStore.getState().status).toBe('playing'); expect(usePlayerStore.getState().errorMessage).toBe(''); - expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize).toHaveBeenCalledOnce(); expect(video.play).toHaveBeenCalledOnce(); expect(video.pause).toHaveBeenCalledTimes(pauses); expect(video.getAttribute('src')).toBe(src); @@ -322,8 +385,7 @@ describe('signed live MSE startup lead', () => { 'ignores the compatible first-frame watchdog after %s', async cancellation => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); - mocks.authorize.mockResolvedValueOnce(null) - .mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); + mocks.authorize.mockResolvedValue('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); await act(async () => hookRef.current?.play()); await act(async () => { if (cancellation === 'stop') hookRef.current?.stop(); @@ -335,7 +397,7 @@ describe('signed live MSE startup lead', () => { const status = usePlayerStore.getState().status; await act(async () => vi.advanceTimersByTimeAsync(65_000)); expect(usePlayerStore.getState().status).toBe(status); - expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize).toHaveBeenCalledOnce(); expect(mocks.mpegtsPlayer.load).not.toHaveBeenCalled(); }, ); @@ -346,23 +408,23 @@ describe('signed live MSE startup lead', () => { if (failure === 'attachment') vi.spyOn(video, 'load').mockImplementation(() => { if (video.getAttribute('src')?.includes('/api/live-compatible/')) throw new Error('Native attachment failed'); }); - mocks.authorize.mockResolvedValueOnce(null) - .mockResolvedValueOnce('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); + mocks.authorize.mockResolvedValueOnce('/api/live-compatible/live_test/index.m3u8?ticket=compatible-test'); await act(async () => hookRef.current?.play()); if (failure === 'media') await act(async () => video.dispatchEvent(new Event('error'))); expect(mocks.mpegtsPlayer.load).not.toHaveBeenCalled(); expect(usePlayerStore.getState().status).toBe('error'); expect(usePlayerStore.getState().errorMessage).toMatch(/compatible.*retry/i); await act(async () => vi.advanceTimersByTimeAsync(65_000)); - expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize).toHaveBeenCalledOnce(); }); - it.each([401, 403])('never tries compatible HLS or raw TS after primary auth %s', async status => { + it.each([401, 403])('never retries or falls back to primary or raw TS after compatible auth %s', async status => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); mocks.authorize.mockRejectedValue(new ApiError(status, 'Synthetic rejection')); await act(async () => hookRef.current?.play()); expect(mocks.authorize).toHaveBeenCalledOnce(); + expect(mocks.authorize.mock.calls[0][3]?.delivery).toBe('compatible'); expect(mocks.mpegtsPlayer.load).not.toHaveBeenCalled(); expect(usePlayerStore.getState().status).toBe('error'); await act(async () => vi.advanceTimersByTimeAsync(65_000)); @@ -372,7 +434,7 @@ describe('signed live MSE startup lead', () => { it('shows a bounded actionable error when compatible authorization remains unavailable', async () => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); - mocks.authorize.mockResolvedValueOnce(null).mockImplementationOnce(() => + mocks.authorize.mockImplementationOnce(() => new Promise(resolve => setTimeout(() => resolve(null), 50_000))); await act(async () => hookRef.current?.play()); expect(usePlayerStore.getState().status).toBe('loading'); @@ -384,7 +446,7 @@ describe('signed live MSE startup lead', () => { video.dispatchEvent(new Event('waiting')); await vi.advanceTimersByTimeAsync(65_000); }); - expect(mocks.authorize).toHaveBeenCalledTimes(2); + expect(mocks.authorize).toHaveBeenCalledOnce(); expect(mocks.mpegtsPlayer.load).not.toHaveBeenCalled(); expect(video.getAttribute('src')).toBeNull(); }); @@ -394,10 +456,10 @@ describe('signed live MSE startup lead', () => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); vi.mocked(video.canPlayType).mockReturnValue('maybe'); let resolveCompatible!: (url: string) => void; - mocks.authorize.mockResolvedValueOnce(null).mockImplementationOnce(() => + mocks.authorize.mockImplementationOnce(() => new Promise(resolve => { resolveCompatible = resolve; })); await act(async () => hookRef.current?.play()); - const isCurrent = mocks.authorize.mock.calls[1][3]!.isCurrent!; + const isCurrent = mocks.authorize.mock.calls[0][3]!.isCurrent!; await act(async () => { if (cancellation === 'stop') hookRef.current?.stop(); else if (cancellation === 'channel switch') usePlayerStore.setState({ currentChannel: { @@ -431,8 +493,15 @@ describe('signed live MSE startup lead', () => { expect(video.play).toHaveBeenCalledOnce(); }); - it.each(['audio', 'finite DVR', 'VOD'])( - 'keeps %s startup unchanged with less than twelve seconds buffered', async kind => { + it.each([ + ['audio', 'desktop'], ['finite DVR', 'desktop'], ['VOD', 'desktop'], + ['audio', 'iPhone'], ['finite DVR', 'iPhone'], ['VOD', 'iPhone'], + ])( + 'keeps %s startup unchanged on %s with less than twelve seconds buffered', async (kind, platform) => { + if (platform === 'iPhone') { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone AppleWebKit/605.1.15 Safari/604.1'); + vi.mocked(video.canPlayType).mockReturnValue('maybe'); + } if (kind === 'audio') usePlayerStore.setState({ audioOnly: true }); else usePlayerStore.setState({ currentChannel: { ...usePlayerStore.getState().currentChannel!, diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index 804447e..4c3e8f2 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -492,7 +492,6 @@ export function usePlayer(): { // browser live playback still carries captions so it can expose only tracks // that the media element actually detects, with Off enforced by track mode. const keepSubsRef = useRef(getSubtitlesEnabled()); - const nativePrimaryFailureRef = useRef<{ channelId: string; backendGeneration: number } | null>(null); const play = useCallback(function play(finiteRetryPosition?: number) { const channel = usePlayerStore.getState().currentChannel; @@ -501,6 +500,7 @@ export function usePlayer(): { return; } + const playStartedAt = Date.now(); const setStatus = usePlayerStore.getState().setStatus; const setError = usePlayerStore.getState().setError; const audioOnly = channel.contentType === 'livetv' && usePlayerStore.getState().audioOnly; @@ -532,14 +532,8 @@ export function usePlayer(): { const nativeVideo = document.getElementById('av-player') as HTMLVideoElement | null; const nativeLiveHls = channel.contentType === 'livetv' && !audioOnly && !channel.dvrHls && !(typeof webapis !== 'undefined' && webapis.avplay) && !!nativeVideo && prefersNativeLiveHls(nativeVideo); - if (nativePrimaryFailureRef.current?.channelId !== channel.id || - nativePrimaryFailureRef.current?.backendGeneration !== backendGeneration) nativePrimaryFailureRef.current = null; - let compatibleLiveHls = nativeLiveHls && !!nativePrimaryFailureRef.current; - const noteNativePrimaryFailure = () => { - if (nativeLiveHls && !compatibleLiveHls && isCurrentAuthorization()) { - nativePrimaryFailureRef.current = { channelId: channel.id, backendGeneration }; - } - }; + // WebKit needs the bounded compatibility feed from the first request, not + // after a primary authorization deadline or a failed first-frame watchdog. const failCompatibleLiveHls = () => { disableLiveStreamRecovery(); setError('Compatible Live TV is unavailable. Check the server connection and tap to retry.'); @@ -816,11 +810,10 @@ export function usePlayer(): { setStatus('loading'); // A successful authorization/attachment is not a decoded first frame. // Stop the compatible startup watchdog instead of reauthorizing forever. - if (nativeLiveHls && compatibleLiveHls && !hasLiveMediaProgress) { + if (nativeLiveHls && !hasLiveMediaProgress) { failCompatibleLiveHls(); return; } - noteNativePrimaryFailure(); play(); }; liveStreamRecovery.begin(channel.id); @@ -1054,6 +1047,9 @@ export function usePlayer(): { } if (!isLiveTs || !isCurrentPlayback() || video.currentTime <= lastMediaTime) return; lastMediaTime = video.currentTime; + if (nativeLiveHls && !hasLiveMediaProgress) { + log.info(`Native live HLS first media channelID=${channel.id} delivery=compatible elapsedms=${Date.now() - playStartedAt}`); + } hasLiveMediaProgress = true; liveStreamRecovery.progress(); recoverDrainedLiveStream(); @@ -1076,8 +1072,7 @@ export function usePlayer(): { return; } if (isLiveTs && isCurrentPlayback()) { - if (nativeLiveHls && compatibleLiveHls) { failCompatibleLiveHls(); return; } - noteNativePrimaryFailure(); + if (nativeLiveHls) { failCompatibleLiveHls(); return; } setStatus('loading'); liveStreamRecovery.transportEnded('mpegts-error'); return; @@ -1090,7 +1085,6 @@ export function usePlayer(): { log.info('HTML5 event: ended'); if (isLiveTs && isCurrentPlayback()) { if (pendingLiveEof) { recoverDrainedLiveStream(true); return; } - noteNativePrimaryFailure(); setStatus('loading'); liveStreamRecovery.transportEnded('media-ended'); return; @@ -1258,7 +1252,6 @@ export function usePlayer(): { video.dataset.streamOffset = '0'; void attachLiveHls(video, playUrl, () => { if (!isCurrentPlayback()) return; - noteNativePrimaryFailure(); setStatus('loading'); liveStreamRecovery.transportEnded('hls-error'); }, isCurrentPlayback, undefined, attemptPlay).then(dispose => { @@ -1267,11 +1260,7 @@ export function usePlayer(): { }).catch(() => { if (!isCurrentPlayback()) return; if (nativeLiveHls) { - if (compatibleLiveHls) failCompatibleLiveHls(); - else { - noteNativePrimaryFailure(); - liveStreamRecovery.transportEnded('hls-error'); - } + failCompatibleLiveHls(); return; } // An engine without native HLS or MSE support keeps the old TS path. @@ -1370,20 +1359,13 @@ export function usePlayer(): { // only after this authorization succeeds and attaches its player. liveStreamRecovery.suspend(); const apiBaseUrl = useChannelStore.getState().apiBaseUrl; + const authorizationStartedAt = Date.now(); void getAuthorizedLiveHlsUrl(apiBaseUrl, channel.id, window.location.origin, { isCurrent: isCurrentAuthorization, - ...(compatibleLiveHls ? { delivery: 'compatible' as const } : {}), + ...(nativeLiveHls ? { delivery: 'compatible' as const } : {}), }) - .then(async url => { + .then(url => { if (!isCurrentAuthorization()) return; - if (!url && nativeLiveHls && !compatibleLiveHls) { - noteNativePrimaryFailure(); - compatibleLiveHls = true; - url = await getAuthorizedLiveHlsUrl(apiBaseUrl, channel.id, window.location.origin, { - delivery: 'compatible', isCurrent: isCurrentAuthorization, - }); - if (!isCurrentAuthorization()) return; - } if (!url && nativeLiveHls) { failCompatibleLiveHls(); return; @@ -1393,6 +1375,11 @@ export function usePlayer(): { .catch(() => { if (!isCurrentAuthorization()) return; setError('Live playback could not be authorized. Check the server connection.'); + }) + .finally(() => { + if (nativeLiveHls && isCurrentAuthorization()) { + log.info(`Native live HLS authorize channelID=${channel.id} delivery=compatible elapsedms=${Date.now() - authorizationStartedAt}`); + } }); } else { // Audio-only keeps the legacy audio-transcoding path until the HLS feed @@ -1402,7 +1389,6 @@ export function usePlayer(): { }, []); const stop = useCallback(() => { - nativePrimaryFailureRef.current = null; playerRef.current = null; subtitleTracksRef.current = []; selectedSubtitleIndexRef.current = -1;