diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index 9e6071fda7..d9533cfece 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -1704,6 +1704,57 @@ describe("syncRuntimeMedia", () => { expect(clip.el.currentTime).toBeGreaterThan(1); }); + it("pulls a WebKit audio element that sits frozen behind the playhead, well before the 1 s valve", () => { + const userAgent = vi + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", + ); + const clip = createMockClip({ start: 0, end: 100, mediaStart: 0 }, "audio"); + Object.defineProperty(clip.el, "paused", { value: false, writable: true, configurable: true }); + Object.defineProperty(clip.el, "readyState", { value: 4 }); + Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true }); + for (const t of [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]) { + syncRuntimeMedia({ clips: [clip], timeSeconds: t, playing: true, playbackRate: 1 }); + } + userAgent.mockRestore(); + expect(clip.el.currentTime).toBeGreaterThan(0.5); + }); + + it("leaves a WebKit audio element alone while it is still filling data", () => { + const userAgent = vi + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", + ); + const clip = createMockClip({ start: 0, end: 100, mediaStart: 0 }, "audio"); + Object.defineProperty(clip.el, "paused", { value: false, writable: true, configurable: true }); + Object.defineProperty(clip.el, "readyState", { value: 2 }); + Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true }); + for (const t of [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]) { + syncRuntimeMedia({ clips: [clip], timeSeconds: t, playing: true, playbackRate: 1 }); + } + userAgent.mockRestore(); + expect(clip.el.currentTime).toBe(0); + }); + + it("leaves a moving WebKit audio element alone at the same lag", () => { + const userAgent = vi + .spyOn(navigator, "userAgent", "get") + .mockReturnValue( + "Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", + ); + const clip = createMockClip({ start: 0, end: 100, mediaStart: 0 }, "audio"); + Object.defineProperty(clip.el, "paused", { value: false, writable: true, configurable: true }); + Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true }); + for (const t of [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]) { + (clip.el as { currentTime: number }).currentTime = Math.max(0, t - 0.6); + syncRuntimeMedia({ clips: [clip], timeSeconds: t, playing: true, playbackRate: 1 }); + } + userAgent.mockRestore(); + expect(clip.el.currentTime).toBeLessThan(0.2); + }); + it("clears offset baseline when clip deactivates — re-entry hard-syncs", () => { const clip = createMockClip({ start: 0, end: 5, mediaStart: 0 }); Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true }); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 44e3286726..165128cdc0 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -37,6 +37,17 @@ export function readElementPlaybackStart(el: Element): number { const HOLD_END_EVENTS = ["seeked", "loadeddata", "error", "emptied", "abort"] as const; export const HOLD_CAP_MS = 5000; const WEBKIT_STUCK_AUDIO_SECONDS = 1; +// A playing WebKit audio element that has not moved for this long is frozen, not starting (it starts ~150 ms late). +const WEBKIT_FROZEN_AUDIO_SECONDS = 0.4; +const lastMoved = new WeakMap(); +function frozenAudioFor(el: HTMLMediaElement, ct: number, now: number): number { + const m = lastMoved.get(el); + if (!m || m.ct !== ct || el.paused || el.seeking || el.readyState < 3) { + lastMoved.set(el, { ct, at: now }); + return 0; + } + return now - m.at; +} const releaseHeldVideo = new WeakMap void>(); // A seeking video still paints its previous frame, and one still fetching its first data paints none (its seek @@ -242,6 +253,7 @@ function clampVolume(volume: number): number { * element at a different file without ever leaving its active window. */ export function evictMediaSyncState(el: HTMLMediaElement): void { + lastMoved.delete(el); lastOffset.delete(el); lastRelativeTime.delete(el); strictDriftSamples.delete(el); @@ -650,8 +662,11 @@ export function syncRuntimeMedia(params: { lastOffset.set(el, offset); const firstTickOfClip = prevOffset === undefined; const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5; + const webkitAudio = el.tagName === "AUDIO" && isWebKit(); + const frozenFor = webkitAudio ? frozenAudioFor(el, currentElTime, params.timeSeconds) : 0; const catastrophicDrift = - drift > (el.tagName === "AUDIO" && isWebKit() ? WEBKIT_STUCK_AUDIO_SECONDS : 3); + drift > (webkitAudio ? WEBKIT_STUCK_AUDIO_SECONDS : 3) || + frozenFor >= WEBKIT_FROZEN_AUDIO_SECONDS; // A short audio clip can leave its native element paused just before EOF. // When the timeline re-enters the clip, rewind stale forward state at the // strict threshold; do not force cold audio forward while it buffers.