fix(core): Safari audio frozen after its cut is pulled onto the playhead at 0.5 s behind, not 1 s (#7082)

* fix(core): Safari audio that sits frozen after its cut is pulled onto the playhead in 0.4 s, not 1 s

* fix(core): shorten the frozen-audio comment

* fix(core): leave a WebKit audio element that is still filling data out of the frozen rule
This commit is contained in:
Miguel Ángel
2026-11-03 11:17:40 +00:00
committed by GitHub
parent 2daedcdafc
commit 47dddedd6c
2 changed files with 67 additions and 1 deletions
+51
View File
@@ -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 });
+16 -1
View File
@@ -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<HTMLMediaElement, { ct: number; at: number }>();
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<HTMLMediaElement, () => 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.