mirror of
https://github.com/lexmount/moli.git
synced 2026-11-13 16:10:01 +00:00
fix(renderer): unblock Feishin media player initialization
This commit is contained in:
@@ -5,6 +5,7 @@ mod gamepad;
|
||||
mod geolocation;
|
||||
mod media_capabilities;
|
||||
mod media_devices;
|
||||
mod media_session;
|
||||
mod navigator;
|
||||
mod navigator_subobjects;
|
||||
mod screen;
|
||||
|
||||
@@ -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<webidl::WebIdlCallbackFunction>,
|
||||
}
|
||||
|
||||
#[derive(webidl::WebIdlArgs)]
|
||||
#[webidl(prefix = "MediaSession.playbackState")]
|
||||
struct PlaybackStateArgs {
|
||||
#[webidl(required)]
|
||||
value: String,
|
||||
}
|
||||
|
||||
pub(super) fn build<'s>(scope: &mut v8::PinScope<'s, '_>) -> Result<v8::Local<'s, v8::Object>> {
|
||||
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::<SetActionHandlerArgs>(scope, &args) else {
|
||||
return;
|
||||
};
|
||||
let handlers = get_private_value(scope, args.this(), HANDLERS)
|
||||
.and_then(|value| v8::Local::<v8::Object>::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::<PlaybackStateArgs>(scope, &args) else {
|
||||
return;
|
||||
};
|
||||
// Like other Web IDL enum attributes, invalid tokens leave the state intact.
|
||||
if let Some(state) = <PlaybackState as webidl::WebIdlEnum>::parse_token(&parsed.value) {
|
||||
let value = v8str(scope, webidl::WebIdlEnumSnapshot::snapshot_token(&state));
|
||||
set_private_value(scope, args.this(), PLAYBACK_STATE, value.into());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::<CreateSourceArgs>(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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = '<main id="root">Player initialized</main>';
|
||||
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';
|
||||
})()
|
||||
@@ -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 = '<iframe></iframe>';
|
||||
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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user