From cdc4423cf3ba3c8d38f411331dec3fa7e4a487bb Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sat, 7 Nov 2026 03:11:51 +0800 Subject: [PATCH] fix(renderer): unblock Feishin media player initialization --- .../context_bootstrap/navigator_runtime.rs | 1 + .../navigator_runtime/media_session.rs | 141 +++++++++++++++ .../navigator_runtime/navigator.rs | 5 + .../navigator_runtime/navigator_subobjects.rs | 6 +- .../src/context_bootstrap/specs/registry.rs | 8 + .../context_bootstrap/web_audio_runtime.rs | 9 + .../web_audio_runtime/gain.rs | 53 ++++++ .../web_audio_runtime/media_element_source.rs | 77 +++++++++ .../web_audio_runtime/node.rs | 3 +- .../tests/browser_api/media_player_init.js | 46 +++++ .../tests/browser_api/media_player_init.rs | 162 ++++++++++++++++++ .../src/script_vm/tests/browser_api/mod.rs | 1 + moli-renderer-v8/src/web_api_interfaces.rs | 2 + 13 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_session.rs create mode 100644 moli-renderer-v8/src/context_bootstrap/web_audio_runtime/gain.rs create mode 100644 moli-renderer-v8/src/context_bootstrap/web_audio_runtime/media_element_source.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/media_player_init.js create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/media_player_init.rs diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs index 9452195477..31c654421c 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs @@ -5,6 +5,7 @@ mod gamepad; mod geolocation; mod media_capabilities; mod media_devices; +mod media_session; mod navigator; mod navigator_subobjects; mod screen; diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_session.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_session.rs new file mode 100644 index 0000000000..1f4723c6ca --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_session.rs @@ -0,0 +1,141 @@ +//! Headless media action registration and playback state. Registrations belong +//! to the Navigator's realm and retain callbacks through traced native slots; +//! they do not synthesize media-key events or control an operating-system player. + +use super::super::*; +use crate::util::{get_private_value, set_private_value}; +use crate::v8_traced_webidl_callback::V8TracedWebIdlCallbackFunction; +use crate::{web_api_interfaces, webidl}; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +const HANDLERS: &str = "__moliMediaSessionHandlers"; +const PLAYBACK_STATE: &str = "__moliMediaSessionPlaybackState"; + +#[derive(WebApiObject)] +#[webapi(interface = web_api_interfaces::MediaSession)] +struct MediaSessionObjectDeclaration { + #[webapi(slot = HANDLERS, init = "null_object")] + handlers: (), + #[webapi(slot = PLAYBACK_STATE)] + playback_state: &'static str, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::MediaSession, enumerable, receiver)] +struct MediaSessionPrototypeDeclaration { + #[webapi(method, length = 2, callback = set_action_handler)] + set_action_handler: (), + #[webapi(accessor_property, getter = playback_state, setter = set_playback_state)] + playback_state: (), +} + +#[derive(Clone, Copy, webidl::WebIdlEnum)] +#[webidl(name = "MediaSessionAction")] +enum MediaSessionAction { + Play, + Pause, + Seekbackward, + Seekforward, + Previoustrack, + Nexttrack, + Skipad, + Stop, + Seekto, + Togglemicrophone, + Togglecamera, + Hangup, + Previousslide, + Nextslide, + Enterpictureinpicture, +} + +#[derive(Clone, Copy, webidl::WebIdlEnum)] +#[webidl(name = "MediaSessionPlaybackState")] +enum PlaybackState { + None, + Paused, + Playing, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "MediaSession.setActionHandler")] +struct SetActionHandlerArgs { + #[webidl(required, converter = "enum")] + action: MediaSessionAction, + #[webidl(required, nullable, converter = "callback_function")] + handler: Option, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "MediaSession.playbackState")] +struct PlaybackStateArgs { + #[webidl(required)] + value: String, +} + +pub(super) fn build<'s>(scope: &mut v8::PinScope<'s, '_>) -> Result> { + MediaSessionObjectDeclaration::new("none") + .bind(scope) + .map_err(|error| anyhow!("failed to bind MediaSession object: {error}")) +} + +pub(super) fn install<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, + interface_name: &str, +) { + if interface_name == "MediaSession" { + MediaSessionPrototypeDeclaration::initialize_prototype_template( + scope, + template.prototype_template(scope), + ); + } +} + +fn set_action_handler<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + let handlers = get_private_value(scope, args.this(), HANDLERS) + .and_then(|value| v8::Local::::try_from(value).ok()) + .expect("MediaSession has native action handlers"); + let action = v8str( + scope, + webidl::WebIdlEnumSnapshot::snapshot_token(&parsed.action), + ); + if let Some(handler) = parsed.handler { + let callback = V8TracedWebIdlCallbackFunction::from_callback(scope, &handler).into_object(); + let _ = handlers.create_data_property(scope, action.into(), callback.into()); + } else { + let _ = handlers.delete(scope, action.into()); + } +} + +fn playback_state<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if let Some(value) = get_private_value(scope, args.this(), PLAYBACK_STATE) { + rv.set(value); + } +} + +fn set_playback_state<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + // Like other Web IDL enum attributes, invalid tokens leave the state intact. + if let Some(state) = ::parse_token(&parsed.value) { + let value = v8str(scope, webidl::WebIdlEnumSnapshot::snapshot_token(&state)); + set_private_value(scope, args.this(), PLAYBACK_STATE, value.into()); + } +} diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs index 661555f76a..a018d0a2fb 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -75,6 +75,7 @@ const NAVIGATOR_RUNTIME_DATA_KEYS: &[&str] = &[ "geolocation", "mediaCapabilities", "credentials", + "mediaSession", ]; const WORKER_NAVIGATOR_INSTALLED_SLOT: &str = "__moliWorkerNavigatorInstalled"; const WORKER_NAVIGATOR_MATERIALIZING_SLOT: &str = "__moliWorkerNavigatorMaterializing"; @@ -193,6 +194,8 @@ struct NavigatorRuntimeDataPrototypeDeclaration { geolocation: (), #[webapi(accessor_property, getter = navigator_runtime_data_getter_callback, data = callback_data_index_value(scope, 32))] media_capabilities: (), + #[webapi(accessor_property, getter = navigator_runtime_data_getter_callback, data = callback_data_index_value(scope, 34), receiver = web_api_interfaces::Navigator::is_instance)] + media_session: (), #[webapi(accessor_property, getter = navigator_cookie_enabled_getter_callback, receiver = web_api_interfaces::Navigator::is_instance)] cookie_enabled: (), } @@ -844,6 +847,7 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>( super::gamepad::install(scope, template, interface_name); install_media_capabilities_template_bindings(scope, template, interface_name); super::credentials::install(scope, template, interface_name); + super::media_session::install(scope, template, interface_name); let prototype = template.prototype_template(scope); match interface_name { "MediaDevices" | "MediaDeviceInfo" | "InputDeviceInfo" => { @@ -1055,6 +1059,7 @@ pub(super) fn build_lazy_navigator_subobject_in_current_realm<'s>( } NavigatorSubobject::Clipboard => build_clipboard_object(scope)?.into(), NavigatorSubobject::Credentials => super::credentials::build_container(scope)?.into(), + NavigatorSubobject::MediaSession => super::media_session::build(scope)?.into(), NavigatorSubobject::UserActivation => build_user_activation(scope)?.into(), NavigatorSubobject::StorageBuckets => { build_storage_bucket_manager(scope, owner_child, owner_popup)?.into() diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs index 29af01ff78..999c539d62 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator_subobjects.rs @@ -22,12 +22,13 @@ pub(super) enum NavigatorSubobject { StorageBuckets, Geolocation, MediaCapabilities, + MediaSession, Credentials, } impl NavigatorSubobject { #[cfg(test)] - pub(super) const ALL: [Self; 17] = [ + pub(super) const ALL: [Self; 18] = [ Self::Languages, Self::MimeTypes, Self::Plugins, @@ -44,6 +45,7 @@ impl NavigatorSubobject { Self::StorageBuckets, Self::Geolocation, Self::MediaCapabilities, + Self::MediaSession, Self::Credentials, ]; @@ -65,6 +67,7 @@ impl NavigatorSubobject { "storageBuckets" => Some(Self::StorageBuckets), "geolocation" => Some(Self::Geolocation), "mediaCapabilities" => Some(Self::MediaCapabilities), + "mediaSession" => Some(Self::MediaSession), "credentials" => Some(Self::Credentials), _ => None, } @@ -88,6 +91,7 @@ impl NavigatorSubobject { Self::StorageBuckets => "storageBuckets", Self::Geolocation => "geolocation", Self::MediaCapabilities => "mediaCapabilities", + Self::MediaSession => "mediaSession", Self::Credentials => "credentials", } } diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index 3270ed43cc..d9d177150e 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -1575,6 +1575,14 @@ const CONSTRUCTOR_SPECS_AFTER_STREAMS: &[ConstructorSpec] = &[ interface: web_api_interfaces::OscillatorNode::DESCRIPTOR, kind: ConstructorKind::OscillatorNode, }, + ConstructorSpec { + interface: web_api_interfaces::GainNode::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, + ConstructorSpec { + interface: web_api_interfaces::MediaElementAudioSourceNode::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, ConstructorSpec { interface: web_api_interfaces::DynamicsCompressorNode::DESCRIPTOR, kind: ConstructorKind::DynamicsCompressorNode, diff --git a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs index 1efa602aad..177c6e2590 100644 --- a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs @@ -10,8 +10,10 @@ use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; mod audio_param; mod biquad; +mod gain; mod graph; mod listener; +mod media_element_source; mod node; use audio_param::{audio_param, detune_param}; @@ -353,6 +355,8 @@ struct AnalyserFftSizeArgs { struct AudioContextTemplateDeclaration { #[webapi(method = "close", length = 0, callback = audio_context_close_callback)] close: (), + #[webapi(method = "createMediaElementSource", length = 1, callback = media_element_source::create, receiver = web_api_interfaces::AudioContext::is_instance)] + create_media_element_source: (), } #[derive(WebApiFunctionTemplate)] @@ -395,6 +399,9 @@ struct BaseAudioContextPrototypeDeclaration { #[webapi(method = "createBiquadFilter", length = 0, callback = biquad::create_biquad_filter)] create_biquad_filter: (), + #[webapi(method = "createGain", length = 0, callback = gain::create)] + create_gain: (), + #[webapi( method = "createOscillator", length = 0, @@ -465,6 +472,8 @@ pub(in crate::context_bootstrap) fn install_web_audio_template_bindings<'s>( "AudioParam" => audio_param::install(scope, template), "AudioListener" => listener::install(scope, template), "BiquadFilterNode" => biquad::install(scope, template), + "GainNode" => gain::install(scope, template), + "MediaElementAudioSourceNode" => media_element_source::install(scope, template), "BaseAudioContext" => { BaseAudioContextPrototypeDeclaration::initialize_prototype_template( scope, diff --git a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/gain.rs b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/gain.rs new file mode 100644 index 0000000000..9d0e4986d0 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/gain.rs @@ -0,0 +1,53 @@ +use super::*; + +const GAIN: &str = "__moliGainNodeGain"; + +#[derive(WebApiObject)] +#[webapi(interface = web_api_interfaces::GainNode)] +struct GainNodeObjectDeclaration<'scope> { + #[webapi(slot = GAIN)] + gain: v8::Local<'scope, v8::Object>, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::GainNode, enumerable, receiver)] +struct GainNodePrototypeDeclaration { + #[webapi(accessor_property, getter = gain)] + gain: (), +} + +pub(super) fn install<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, +) { + GainNodePrototypeDeclaration::initialize_prototype_template( + scope, + template.prototype_template(scope), + ); +} + +pub(super) fn create<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if !require_base_audio_context(scope, args.this()) { + return; + } + let gain = audio_param(scope, 1.0, f32::MIN as f64, f32::MAX as f64); + let node = GainNodeObjectDeclaration::new(gain) + .bind(scope) + .expect("GainNode declaration should bind"); + graph::initialize_node(scope, node, args.this()); + rv.set(node.into()); +} + +fn gain<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if let Some(value) = get_private_value(scope, args.this(), GAIN) { + rv.set(value); + } +} diff --git a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/media_element_source.rs b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/media_element_source.rs new file mode 100644 index 0000000000..ba5166df66 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/media_element_source.rs @@ -0,0 +1,77 @@ +//! Media-element source ownership and graph wiring for the existing partial +//! audio backend. Decoded audio transport remains with the media backend. + +use super::*; + +const ELEMENT: &str = "__moliAudioSourceMediaElement"; +const SOURCE: &str = "__moliMediaElementAudioSource"; + +#[derive(WebApiObject)] +#[webapi(interface = web_api_interfaces::MediaElementAudioSourceNode)] +struct MediaElementSourceObjectDeclaration<'scope> { + #[webapi(slot = ELEMENT)] + element: v8::Local<'scope, v8::Object>, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::MediaElementAudioSourceNode, enumerable, receiver)] +struct MediaElementSourcePrototypeDeclaration { + #[webapi(accessor_property, getter = media_element)] + media_element: (), +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "AudioContext.createMediaElementSource")] +struct CreateSourceArgs<'s> { + #[webidl(required, interface = web_api_interfaces::HTMLMediaElement)] + media_element: v8::Local<'s, v8::Object>, +} + +pub(super) fn install<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, +) { + MediaElementSourcePrototypeDeclaration::initialize_prototype_template( + scope, + template.prototype_template(scope), + ); +} + +pub(super) fn create<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + let element = parsed.media_element; + if get_private_value(scope, element, SOURCE).is_some() { + throw_dom_exception( + scope, + "InvalidStateError", + 11, + "The media element is already connected to an audio source node.", + ); + return; + } + let node = MediaElementSourceObjectDeclaration::new(element) + .bind(scope) + .expect("MediaElementAudioSourceNode declaration should bind"); + graph::initialize_node(scope, node, args.this()); + // A source has no input port. It is driven by its media element, not by + // AudioScheduledSourceNode.start() or the synthetic offline oscillator. + graph::initialize_source(scope, node); + set_private_value(scope, element, SOURCE, node.into()); + rv.set(node.into()); +} + +fn media_element<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if let Some(value) = get_private_value(scope, args.this(), ELEMENT) { + rv.set(value); + } +} diff --git a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/node.rs b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/node.rs index a413f866ce..fdf9d52083 100644 --- a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/node.rs +++ b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime/node.rs @@ -54,7 +54,8 @@ pub(super) fn install<'s>( } pub(super) fn initialize<'s>(scope: &mut v8::PinScope<'s, '_>, node: v8::Local<'s, v8::Object>) { - let source = web_api_interfaces::OscillatorNode::is_instance(scope, node); + let source = web_api_interfaces::OscillatorNode::is_instance(scope, node) + || web_api_interfaces::MediaElementAudioSourceNode::is_instance(scope, node); let destination = web_api_interfaces::AudioDestinationNode::is_instance(scope, node); let compressor = web_api_interfaces::DynamicsCompressorNode::is_instance(scope, node); set_web_audio_number_slot(scope, node, INPUT_COUNT, if source { 0.0 } else { 1.0 }); diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/media_player_init.js b/moli-renderer-v8/src/script_vm/tests/browser_api/media_player_init.js new file mode 100644 index 0000000000..81ee1db031 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/media_player_init.js @@ -0,0 +1,46 @@ +// Feishin's startup registers media controls before connecting two playback +// gains through a preamp, equalizer and compressor. Both missing APIs used to +// throw during React effects and leave the application root empty. +// Also run against Chrome 157.0.8103.38 as the compatibility oracle. +(() => { + const assert = (ok, message) => { if (!ok) throw new Error(message); }; + const session = navigator.mediaSession; + const actions = ['nexttrack', 'pause', 'play', 'previoustrack', 'seekto', 'stop', 'seekbackward', 'seekforward']; + for (const action of actions) session.setActionHandler(action, () => {}); + const context = new AudioContext({latencyHint: 'playback'}); + const gains = [context.createGain(), context.createGain()]; + const preamp = context.createGain(); + preamp.gain.value = 1; + const filters = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].map(frequency => { + const filter = context.createBiquadFilter(); + filter.type = 'peaking'; + filter.frequency.value = frequency; + filter.Q.value = 1.41; + filter.gain.value = 0; + return filter; + }); + const compressor = context.createDynamicsCompressor(); + compressor.threshold.value = 0; + compressor.ratio.value = 1; + compressor.attack.value = 0; + compressor.release.value = 0.25; + compressor.knee.value = 0; + for (const gain of gains) assert(gain.connect(preamp) === preamp, 'playback gains'); + preamp.connect(filters[0]); + for (let i = 0; i < filters.length - 1; i++) filters[i].connect(filters[i + 1]); + filters.at(-1).connect(compressor); + compressor.connect(context.destination); + const sources = gains.map(gain => { + const element = document.createElement('audio'); + const source = context.createMediaElementSource(element); + assert(source.mediaElement === element, 'media element ownership'); + source.connect(gain); + return source; + }); + document.body.innerHTML = '
Player initialized
'; + assert(document.getElementById('root').textContent === 'Player initialized', 'startup completed'); + for (const action of actions) session.setActionHandler(action, null); + for (const node of [...sources, ...gains, preamp, ...filters, compressor]) node.disconnect(); + context.close(); + return 'ok'; +})() diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/media_player_init.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/media_player_init.rs new file mode 100644 index 0000000000..504e86eff7 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/media_player_init.rs @@ -0,0 +1,162 @@ +use super::*; + +#[test] +fn media_player_initialization_can_register_controls_and_connect_gain_nodes() { + let mut vm = new_storage_html_test_vm("http://media-player.test/"); + assert_eq!( + vm.eval(include_str!("media_player_init.js")) + .expect("media-player initialization should complete"), + "ok" + ); +} + +#[test] +fn media_session_is_same_object_with_native_receivers_and_per_window_state() { + let mut vm = new_storage_html_test_vm("http://media-session.test/"); + assert_eq!( + vm.eval(r#" +(() => { + const assert = (ok, message) => { if (!ok) throw new Error(message); }; + const typeError = (fn) => { + try { fn(); } catch (error) { assert(error.name === 'TypeError', error.name); return; } + throw new Error('expected TypeError'); + }; + const session = navigator.mediaSession; + assert(session === navigator.mediaSession && session instanceof MediaSession, 'SameObject'); + assert(Object.getPrototypeOf(session) === MediaSession.prototype, 'native prototype'); + assert(!Object.hasOwn(navigator, 'mediaSession'), 'prototype getter'); + assert(Reflect.set(navigator, 'mediaSession', {}) === false, 'readonly'); + assert(session.playbackState === 'none', 'initial playback state'); + typeError(() => new MediaSession()); + assert(session.setActionHandler.length === 2, 'method arity'); + for (const action of ['play','pause','seekbackward','seekforward','previoustrack', + 'nexttrack','skipad','stop','seekto','togglemicrophone','togglecamera','hangup', + 'previousslide','nextslide','enterpictureinpicture']) { + assert(session.setActionHandler(action, () => { throw new Error('must not invoke'); }) === undefined, 'register'); + session.setActionHandler(action, null); + session.setActionHandler(action, undefined); + } + typeError(() => session.setActionHandler()); + typeError(() => session.setActionHandler('play')); + typeError(() => session.setActionHandler('unknown', () => {})); + for (const bad of [0, 'callback', {}, {handleEvent(){}}]) { + typeError(() => session.setActionHandler('play', bad)); + } + const marker = new Error('conversion'); + try { session.setActionHandler({toString(){throw marker;}}, null); } + catch (e) { assert(e === marker, 'action conversion exception'); } + for (const state of ['playing','paused','none']) { + session.playbackState = state; + assert(session.playbackState === state, 'playback state '+state); + session.playbackState = 'invalid'; + assert(session.playbackState === state, 'invalid enum ignored'); + } + typeError(() => {session.playbackState = Symbol();}); + const descriptor = Object.getOwnPropertyDescriptor(MediaSession.prototype, 'playbackState'); + for (const bad of [{}, MediaSession.prototype, Object.create(session), new Proxy(session,{})]) { + typeError(() => session.setActionHandler.call(bad, 'play', null)); + typeError(() => descriptor.get.call(bad)); + typeError(() => descriptor.set.call(bad, 'playing')); + } + document.body.innerHTML = ''; + const other = document.querySelector('iframe').contentWindow; + const getter = Object.getOwnPropertyDescriptor(Navigator.prototype, 'mediaSession').get; + const otherSession = getter.call(other.navigator); + assert(otherSession === other.navigator.mediaSession && otherSession !== session, 'per-window identity'); + assert(Object.getPrototypeOf(otherSession) === other.MediaSession.prototype, 'receiver realm'); + otherSession.playbackState = 'playing'; + assert(session.playbackState === 'none', 'isolated state'); + session.setActionHandler.call(otherSession, 'play', null); + return 'ok'; +})() +"#).expect("MediaSession identity and Web IDL contracts should evaluate"), + "ok" + ); +} + +#[test] +fn gain_factory_creates_independent_parameters_and_validates_graph_connections() { + let mut vm = new_storage_html_test_vm("https://gain-node.test/"); + assert_eq!(vm.eval(r#" +(() => { + const assert = (ok, message) => { if (!ok) throw new Error(message); }; + const throws = (name, fn) => { + try { fn(); } catch (e) { assert(e.name === name, e.name+' != '+name); return; } + throw new Error('expected '+name); + }; + assert(Object.hasOwn(BaseAudioContext.prototype, 'createGain'), 'shared factory'); + assert(BaseAudioContext.prototype.createGain.length === 0, 'factory arity'); + for (const context of [new AudioContext(), new OfflineAudioContext(1,64,44100)]) { + const gain = context.createGain(), other = context.createGain(); + assert(gain instanceof GainNode && gain instanceof AudioNode, 'node inheritance'); + assert(gain.context === context, 'context ownership'); + assert(gain.numberOfInputs === 1 && gain.numberOfOutputs === 1, 'ports'); + assert(gain.channelCount === 2 && gain.channelCountMode === 'max' && gain.channelInterpretation === 'speakers', 'channel defaults'); + assert(gain.gain instanceof AudioParam && gain.gain === gain.gain, 'SameObject parameter'); + assert(gain.gain.value === 1 && gain.gain.defaultValue === 1, 'default gain'); + assert(gain.gain.minValue === -3.4028234663852886e38 && gain.gain.maxValue === 3.4028234663852886e38, 'float bounds'); + assert(!Object.hasOwn(gain,'gain') && Reflect.set(gain,'gain',{}) === false, 'readonly parameter'); + gain.gain.value = 0.5; + assert(other.gain.value === 1 && gain.gain.defaultValue === 1, 'independent parameters'); + assert(gain.gain.setValueAtTime(0.25,0) === gain.gain, 'existing AudioParam methods'); + assert(gain.connect(other) === other && other.connect(context.destination) === context.destination, 'graph connectivity'); + throws('InvalidAccessError', () => gain.connect(new AudioContext().destination)); + throws('IndexSizeError', () => gain.connect(other,1)); + assert(gain.disconnect(other) === undefined, 'remove connection'); + throws('InvalidAccessError', () => gain.disconnect(other)); + const getter = Object.getOwnPropertyDescriptor(GainNode.prototype,'gain').get; + for (const bad of [{}, GainNode.prototype, Object.create(gain), new Proxy(gain,{})]) { + throws('TypeError', () => getter.call(bad)); + } + for (const bad of [{}, BaseAudioContext.prototype, Object.create(context), new Proxy(context,{})]) { + throws('TypeError', () => BaseAudioContext.prototype.createGain.call(bad)); + } + } + return 'ok'; +})() +"#).expect("GainNode factory and graph contracts should evaluate"), "ok"); +} + +#[test] +fn media_element_audio_source_validates_ownership_receivers_and_ports() { + let mut vm = new_storage_html_test_vm("http://media-source-node.test/"); + assert_eq!(vm.eval(r#" +(() => { + const assert = (ok, message) => { if (!ok) throw new Error(message); }; + const throws = (name, fn) => { + try { fn(); } catch (e) { assert(e.name === name, e.name+' != '+name); return; } + throw new Error('expected '+name); + }; + const context = new AudioContext(); + assert(context.createMediaElementSource.length === 1, 'method arity'); + assert(typeof OfflineAudioContext.prototype.createMediaElementSource === 'undefined', 'realtime only'); + for (const tag of ['audio','video']) { + const element = document.createElement(tag); + const source = context.createMediaElementSource(element); + assert(source instanceof MediaElementAudioSourceNode && source instanceof AudioNode, 'inheritance'); + assert(source.context === context && source.mediaElement === element, 'ownership'); + assert(source.numberOfInputs === 0 && source.numberOfOutputs === 1, 'source ports'); + assert(Reflect.set(source,'mediaElement',{}) === false, 'readonly media element'); + const gain = context.createGain(); + assert(source.connect(gain) === gain, 'source connection'); + throws('IndexSizeError', () => gain.connect(source)); + throws('InvalidStateError', () => context.createMediaElementSource(element)); + throws('InvalidStateError', () => new AudioContext().createMediaElementSource(element)); + source.disconnect(); + throws('InvalidStateError', () => context.createMediaElementSource(element)); + assert(context.createMediaElementSource(element.cloneNode()).mediaElement !== element, 'clone has independent ownership'); + const getter = Object.getOwnPropertyDescriptor(MediaElementAudioSourceNode.prototype,'mediaElement').get; + for (const bad of [{}, Object.create(source), new Proxy(source,{})]) { + throws('TypeError', () => getter.call(bad)); + } + } + for (const bad of [undefined, null, {}, document.createElement('div'), Object.create(new Audio()), new Proxy(new Audio(),{})]) { + throws('TypeError', () => context.createMediaElementSource(bad)); + } + for (const bad of [{}, new OfflineAudioContext(1,64,44100), Object.create(context), new Proxy(context,{})]) { + throws('TypeError', () => context.createMediaElementSource.call(bad,new Audio())); + } + return 'ok'; +})() +"#).expect("MediaElementAudioSourceNode contracts should evaluate"), "ok"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 734cc5af3a..134bf4f4f1 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -42,6 +42,7 @@ mod input_event_init; mod legacy_event_init; mod media; mod media_devices; +mod media_player_init; mod media_query_list_events; mod message_channel; mod message_port_events; diff --git a/moli-renderer-v8/src/web_api_interfaces.rs b/moli-renderer-v8/src/web_api_interfaces.rs index 1bb85c0b39..90b101013b 100644 --- a/moli-renderer-v8/src/web_api_interfaces.rs +++ b/moli-renderer-v8/src/web_api_interfaces.rs @@ -190,6 +190,7 @@ interfaces! { FormData; FormDataIterator = "FormData Iterator"; FormDataEvent: Event; + GainNode: AudioNode; Geolocation; GeolocationCoordinates; GeolocationPosition; @@ -321,6 +322,7 @@ interfaces! { MediaStreamTrack: EventTarget; CanvasCaptureMediaStreamTrack: MediaStreamTrack; MediaDevices: EventTarget; + MediaElementAudioSourceNode: AudioNode; MediaError; MediaKeySession: EventTarget; MediaKeyStatusMap;