fix(media): dispatch canplaythrough after canplay

This commit is contained in:
ldm0
2026-10-19 04:24:23 +08:00
committed by Donough Liu
parent 7458b20a02
commit 45f3211939
4 changed files with 260 additions and 7 deletions
@@ -554,6 +554,7 @@ pub(crate) enum MediaLoadEventPhase {
LoadedMetadata,
LoadedData,
CanPlay,
CanPlayThrough,
Error,
}
@@ -564,6 +565,7 @@ impl MediaLoadEventPhase {
Self::LoadedMetadata => "loadedmetadata",
Self::LoadedData => "loadeddata",
Self::CanPlay => "canplay",
Self::CanPlayThrough => "canplaythrough",
Self::Error => "error",
}
}
@@ -573,7 +575,8 @@ impl MediaLoadEventPhase {
Self::LoadStart => Some(Self::LoadedMetadata),
Self::LoadedMetadata => Some(Self::LoadedData),
Self::LoadedData => Some(Self::CanPlay),
Self::CanPlay | Self::Error => None,
Self::CanPlay => Some(Self::CanPlayThrough),
Self::CanPlayThrough | Self::Error => None,
}
}
}
@@ -604,7 +607,7 @@ pub(crate) fn dispatch_media_load_event_phase(
return false;
}
match phase {
MediaLoadEventPhase::LoadStart => {}
MediaLoadEventPhase::LoadStart | MediaLoadEventPhase::CanPlayThrough => {}
MediaLoadEventPhase::LoadedMetadata => {
let _ = unsafe { &mut *runtime_ptr }.set_media_ready_state(handle, 1);
}
@@ -383,3 +383,212 @@ emptyMedia.volume === 0.25 && !emptyMedia.muted && emptyMedia.defaultMuted"#,
.await
.expect("empty local media uses its owned error task without readiness events");
}
#[tokio::test(flavor = "current_thread")]
async fn media_canplaythrough_runs_in_a_separate_owned_task_after_canplay_microtasks() {
run_page_vm_async_test(async move {
let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())
.expect("loader");
let (mut page, _source, _wake) = page_vm_with_bound_task_sources_and_owner_wake(
&loader, Url::parse("https://media-canplaythrough.test/")?,
);
page.vm_mut().set_layout_policy(moli_page_types::LayoutPolicy::Mock);
page.vm_mut().eval(r#"
globalThis.mediaEvents = [];
globalThis.media = document.createElement('video');
for (const type of ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough']) {
media.addEventListener(type, event => {
mediaEvents.push(type + ':' + media.readyState);
if (type === 'canplay' || type === 'canplaythrough') {
Promise.resolve().then(() => mediaEvents.push(type + '-microtask'));
}
});
}
media.oncanplay = () => setTimeout(() => mediaEvents.push('timer'), 0);
document.body.append(media);
media.src = 'data:video/mp4;base64,AAAAGGZ0eXBtcDQyAAAAAG1wNDJtcDQx';
"#)?;
for _ in 0..4 {
let turn = page.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
).expect("each load phase must have its own media-element task");
page.run_claimed_selected_page_task_for_test(turn, &loader).await?;
}
assert_eq!(page.vm_mut().eval("mediaEvents.join('|')")?,
"loadstart:0|loadedmetadata:1|loadeddata:2|canplay:4|canplay-microtask");
assert!(page.vm().has_ready_timeout(), "the unrelated timer must remain queued");
let through = page.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
).expect("HAVE_ENOUGH_DATA must queue canplaythrough after canplay");
page.run_claimed_selected_page_task_for_test(through, &loader).await?;
assert_eq!(page.vm_mut().eval("mediaEvents.join('|')")?,
"loadstart:0|loadedmetadata:1|loadeddata:2|canplay:4|canplay-microtask|canplaythrough:4|canplaythrough-microtask");
assert_eq!(page.vm_mut().eval("media.paused && media.networkState === 1")?, "true");
assert!(page.vm().has_ready_timeout(), "canplaythrough must not consume timers");
assert!(page.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
).is_none(), "the terminal phase must finish the load sequence exactly once");
Ok::<_, anyhow::Error>(())
}).await.expect("canplaythrough task ordering and timer isolation");
}
#[tokio::test(flavor = "current_thread")]
async fn media_canplaythrough_from_a_retired_document_does_not_checkpoint_the_replacement() {
run_page_vm_async_test(async move {
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
let (mut page, _source, _wake) = page_vm_with_bound_task_sources_and_owner_wake(
&loader,
Url::parse("https://retired-media-canplaythrough.test/")?,
);
page.vm_mut()
.set_layout_policy(moli_page_types::LayoutPolicy::Mock);
page.vm_mut().eval(
r#"
globalThis.retiredMediaEvents = [];
const media = document.createElement('video');
media.oncanplaythrough = () => retiredMediaEvents.push('stale canplaythrough');
document.body.append(media);
media.src = 'data:video/mp4;base64,AAAAGGZ0eXBtcDQyAAAAAG1wNDJtcDQx';
"#,
)?;
for _ in 0..4 {
let turn = page
.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
)
.expect("load phase before Document retirement");
page.run_claimed_selected_page_task_for_test(turn, &loader)
.await?;
}
let retired = page
.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
)
.expect("capture the terminal task before its Document is retired");
page.vm_mut().eval(
"document.open();document.write('<!doctype html><body>replacement</body>');document.close();",
)?;
page.vm_mut().eval_without_microtask_checkpoint_for_test(
r#"
globalThis.replacementMediaEvents = [];
Promise.resolve().then(() => replacementMediaEvents.push('replacement microtask'));
"#,
)?;
page.vm_mut().enqueue_test_pending_runtime_source_load();
page.run_claimed_selected_page_task_for_test(retired, &loader)
.await?;
assert_eq!(
page.vm_mut().eval_without_microtask_checkpoint_for_test(
"retiredMediaEvents.join('|') + ':' + replacementMediaEvents.join('|')",
)?,
":"
);
assert_eq!(
page.vm()
.document_runtime
.runtime_script_work()
.dynamic_scripts
.pending_source_load_count_for_test(),
1,
"retired terminal events must not advance replacement script work"
);
assert!(
page.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
)
.is_none()
);
Ok::<_, anyhow::Error>(())
})
.await
.expect("retired canplaythrough owner and completion isolation");
}
#[tokio::test(flavor = "current_thread")]
async fn media_canplaythrough_is_cancelled_when_canplay_replaces_the_load_sequence() {
run_page_vm_async_test(async move {
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
let (mut page, _source, _wake) = page_vm_with_bound_task_sources_and_owner_wake(
&loader,
Url::parse("https://replaced-media-canplaythrough.test/")?,
);
page.vm_mut()
.set_layout_policy(moli_page_types::LayoutPolicy::Mock);
page.vm_mut().eval(
r#"
globalThis.replacedMediaEvents = [];
globalThis.media = document.createElement('video');
let first = true;
media.oncanplay = () => {
replacedMediaEvents.push('canplay');
if (first) {
first = false;
Promise.resolve().then(() => {
media.src = 'data:video/mp4;base64,AAAAGGZ0eXBtcDQyAAAAAG1wNDJtcDQxAA==';
});
}
};
media.oncanplaythrough = () => replacedMediaEvents.push('canplaythrough');
document.body.append(media);
media.src = 'data:video/mp4;base64,AAAAGGZ0eXBtcDQyAAAAAG1wNDJtcDQx';
"#,
)?;
for _ in 0..4 {
let turn = page
.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
)
.expect("first load phase");
page.run_claimed_selected_page_task_for_test(turn, &loader)
.await?;
}
assert_eq!(
page.vm_mut().eval("replacedMediaEvents.join('|')")?,
"canplay"
);
let restarted = page
.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
)
.expect("consume the old terminal retained ahead of the replacement");
page.run_claimed_selected_page_task_for_test(restarted, &loader)
.await?;
assert_eq!(page.vm_mut().eval("media.readyState")?, "0");
assert_eq!(
page.vm_mut().eval("replacedMediaEvents.join('|')")?,
"canplay"
);
let loadstart = page
.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
)
.expect("replacement loadstart follows the discarded old terminal");
page.run_claimed_selected_page_task_for_test(loadstart, &loader)
.await?;
assert_eq!(page.vm_mut().eval("media.readyState")?, "0");
for _ in 0..4 {
let turn = page
.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
)
.expect("replacement load phase");
page.run_claimed_selected_page_task_for_test(turn, &loader)
.await?;
}
assert_eq!(
page.vm_mut().eval("replacedMediaEvents.join('|')")?,
"canplay|canplay|canplaythrough"
);
assert!(
page.claim_exact_selected_page_task_for_test(
PageSelectedTaskTestSelector::MediaElementEvent,
)
.is_none()
);
Ok::<_, anyhow::Error>(())
})
.await
.expect("load sequence replacement cancels only the old terminal event");
}
@@ -1336,7 +1336,7 @@ async fn main_static_media_load_delays_complete_until_loadeddata_owner_turn() {
r#"
globalThis.__mainMediaLifecycleEvents = [];
const clip = document.getElementById("clip");
for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay"]) {
for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay", "canplaythrough"]) {
clip.addEventListener(type, () => {
__mainMediaLifecycleEvents.push(`${type}:${document.readyState}`);
});
@@ -1421,6 +1421,24 @@ async fn main_static_media_load_delays_complete_until_loadeddata_owner_turn() {
));
run_next_page_media_element_event_for_test(&mut vm, &loader, "canplay owner turn").await;
assert!(
vm._context_host
.borrow()
.pending_media_load_sequence(media)
.is_some(),
"canplay must leave the terminal event owned by this sequence"
);
assert_eq!(
vm.eval("__mainMediaLifecycleEvents.some(event => event.startsWith('canplaythrough:'))")
.unwrap(),
"false"
);
run_next_page_media_element_event_for_test(&mut vm, &loader, "canplaythrough owner turn").await;
assert_eq!(
vm.eval("__mainMediaLifecycleEvents.at(-1).startsWith('canplaythrough:')")
.unwrap(),
"true"
);
assert!(
vm._context_host
.borrow()
@@ -1985,7 +2003,7 @@ async fn main_text_track_network_terminal_gates_canplay_without_delaying_complet
globalThis.__trackMediaEvents = [];
const clip = document.getElementById("clip");
const captions = document.getElementById("captions");
for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay"]) {
for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay", "canplaythrough"]) {
clip.addEventListener(type, () => __trackMediaEvents.push(type));
}
captions.addEventListener("load", () => {
@@ -2131,6 +2149,23 @@ async fn main_text_track_network_terminal_gates_canplay_without_delaying_complet
.expect("canplay event trace"),
"loadstart|loadedmetadata|loadeddata|track-load:2:1|canplay"
);
assert!(
vm._context_host
.borrow()
.pending_media_load_sequence(media)
.is_some(),
"canplay must retain its canplaythrough continuation"
);
run_next_page_media_element_event_for_test(
&mut vm,
&loader,
"media canplaythrough follow-up owner turn",
)
.await;
assert_eq!(
vm.eval("__trackMediaEvents.join('|')").unwrap(),
"loadstart|loadedmetadata|loadeddata|track-load:2:1|canplay|canplaythrough"
);
assert!(
vm._context_host
.borrow()
@@ -476,7 +476,7 @@ async fn default_video_fetch_policy_synthesizes_lifecycle_without_a_network_requ
globalThis.__defaultVideoPolicyEvents = [];
const video = document.createElement("video");
video.id = "default-video-policy";
for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay", "error"]) {
for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay", "canplaythrough", "error"]) {
video.addEventListener(type, () => __defaultVideoPolicyEvents.push(type));
}
video.src = "https://media-policy.test/large-video.mp4";
@@ -511,7 +511,13 @@ async fn default_video_fetch_policy_synthesizes_lifecycle_without_a_network_requ
"policy-skipped video must not emit a requestWillBeSent source record"
);
for phase in ["loadstart", "loadedmetadata", "loadeddata", "canplay"] {
for phase in [
"loadstart",
"loadedmetadata",
"loadeddata",
"canplay",
"canplaythrough",
] {
run_next_page_media_element_event_for_test(
&mut vm,
&loader,
@@ -524,7 +530,7 @@ async fn default_video_fetch_policy_synthesizes_lifecycle_without_a_network_requ
"JSON.stringify({events: __defaultVideoPolicyEvents, ready: document.getElementById('default-video-policy').readyState, network: document.getElementById('default-video-policy').networkState})"
)
.expect("default-policy video terminal state"),
r#"{"events":["loadstart","loadedmetadata","loadeddata","canplay"],"ready":4,"network":1}"#
r#"{"events":["loadstart","loadedmetadata","loadeddata","canplay","canplaythrough"],"ready":4,"network":1}"#
);
assert!(
vm._context_host