···11+--- original
22++++ modified
33+@@ -71,6 +71,10 @@
44+ /// All the pipelines that have been presented or will be presented in
55+ /// this browsing context.
66+ pub pipelines: FxHashSet<PipelineId>,
77++
88++ /// Whether spatial navigation is enabled for this browsing context.
99++ /// When set, new documents in this context will have spatial navigation enabled.
1010++ pub spatial_navigation: bool,
1111+ }
1212+1313+ impl BrowsingContext {
1414+@@ -101,6 +105,7 @@
1515+ pipeline_id,
1616+ parent_pipeline_id,
1717+ pipelines,
1818++ spatial_navigation: false,
1919+ }
2020+ }
2121+
···11+--- original
22++++ modified
33+@@ -10,5 +10,6 @@
44+ pub(crate) mod documentorshadowroot;
55+ pub(crate) mod documenttype;
66+ pub(crate) mod focus;
77++pub(crate) mod spatial_navigation;
88+99+ pub(crate) use self::document::*;
···11--- original
22+++ modified
33-@@ -0,0 +1,1109 @@
33+@@ -0,0 +1,1130 @@
44+/* This Source Code Form is subject to the terms of the Mozilla Public
55+ * License, v. 2.0. If a copy of the MPL was not distributed with this
66+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
···10501050+ .send(ScriptToConstellationMessage::EmbeddedWebViewFocus(
10511051+ webview_id,
10521052+ ))
10531053++ .unwrap();
10541054++ Ok(())
10551055++ }
10561056++
10571057++ /// Enable or disable spatial navigation for this embedded webview.
10581058++ pub(crate) fn embedded_use_spatial_navigation(&self, enabled: bool) -> Fallible<()> {
10591059++ let Some(webview_id) = self.embedded_webview_id() else {
10601060++ return Err(Error::InvalidState(Some(
10611061++ "This iframe is not an embedded webview".to_string(),
10621062++ )));
10631063++ };
10641064++
10651065++ let window = self.owner_window();
10661066++ window
10671067++ .as_global_scope()
10681068++ .script_to_constellation_chan()
10691069++ .send(
10701070++ ScriptToConstellationMessage::EmbeddedWebViewUseSpatialNavigation(
10711071++ webview_id, enabled,
10721072++ ),
10731073++ )
10531074+ .unwrap();
10541075+ Ok(())
10551076+ }
···11--- original
22+++ modified
33-@@ -0,0 +1,176 @@
33+@@ -0,0 +1,179 @@
44+/* This Source Code Form is subject to the terms of the Mozilla Public
55+ * License, v. 2.0. If a copy of the MPL was not distributed with this
66+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
···4949+
5050+ // Focus control — transfers input focus to this embedded webview
5151+ [Throws] undefined forceFocus();
5252++
5353++ // Spatial navigation — enable/disable arrow key focus navigation in this webview
5454++ [Throws] undefined useSpatialNavigation(boolean enabled);
5255+};
5356+
5457+
···209209 /// Mark a new document as active
210210 ActivateDocument,
211211 /// Set the document state for a pipeline (used by screenshot / reftests)
212212-@@ -738,6 +871,114 @@
212212+@@ -738,6 +871,116 @@
213213 /// aggregate lock count and notify the provider only when the count transitions from N to 0.
214214 /// <https://w3c.github.io/screen-wake-lock/#dfn-release-wake-lock>
215215 ReleaseWakeLock(WakeLockType),
···238238+ EmbeddedWebViewMediaSessionAction(embedder_traits::MediaSessionActionType),
239239+ /// Transfer input focus to an embedded webview.
240240+ EmbeddedWebViewFocus(WebViewId),
241241++ /// Enable or disable spatial navigation for an embedded webview.
242242++ EmbeddedWebViewUseSpatialNavigation(WebViewId, bool),
241243+ /// Forward an input event to an embedded webview after the parent's DOM hit testing
242244+ /// determined that the event target is an embedded iframe element.
243245+ ForwardEventToEmbeddedWebView(WebViewId, InputEventAndId),
+3-1
patches/components/shared/script/lib.rs.patch
···5252 /// Notify the `ScriptThread` that the Servo renderer is no longer waiting on
5353 /// asynchronous image uploads for the given `Pipeline`. These are mainly used
5454 /// by canvas to perform uploads while the display list is being built.
5555-@@ -326,6 +344,24 @@
5555+@@ -326,6 +344,26 @@
5656 SetAccessibilityActive(PipelineId, bool, Epoch),
5757 /// Force a garbage collection in this script thread.
5858 TriggerGarbageCollection,
···7474+ /// Dispatch a peer stream event — a remote peer is offering a MessagePort.
7575+ /// Contains (peer_id, serialized remote port_id bytes, stream_id, from_peer_id, target_url).
7676+ DispatchPeerStream(String, Vec<u8>, String, String, String),
7777++ /// Enable or disable spatial navigation for a document.
7878++ SetSpatialNavigation(PipelineId, bool),
7779 }
78807981 impl fmt::Debug for ScriptThreadMessage {
+42-115
ui/system/index.js
···44import { LayoutManager } from "./layout_manager.js";
55import { MobileLayoutManager } from "./mobile_layout_manager.js";
66import { PairingHandler } from "./p2p.js";
77+import { MediaBroadcaster } from "./media_broadcast.js";
78import "./system_menu.js";
89import "./mobile_action_bar.js";
910import "./mobile_notification_sheet.js";
···920921 let mediaSessionWebviewId = null;
921922922923 // P2P media control: broadcast local media state and receive remote state.
923923- // Every broadcast includes the full accumulated state so late joiners get everything.
924924- const mediaChannel = new BroadcastChannel("beaver-media-control");
925925- let localDeviceName = null;
926926- let localSessionId = null;
927927- let localMediaState = {
928928- title: "",
929929- artist: "",
930930- album: "",
931931- playbackState: "none",
932932- duration: 0,
933933- position: 0,
934934- };
935924 const remoteMediaControls = new Map(); // sessionId -> media-control element
936925937937- mediaChannel.onmessage = (e) => {
938938- handleRemoteMediaMessage(e.data);
939939- };
926926+ const mediaBroadcaster = new MediaBroadcaster({
927927+ deviceName: null, // resolved lazily below
928928+ onRemoteAction: (action) => {
929929+ // A remote device is requesting an action on our media session.
930930+ if (mediaSessionWebviewId == null) return;
931931+ const entry = layoutManager.webviews.get(mediaSessionWebviewId);
932932+ if (entry) {
933933+ entry.webview.ensureIframe();
934934+ entry.webview.iframe.mediaSessionAction(action);
935935+ }
936936+ },
937937+ onRemoteState: (data) => {
938938+ // A remote device is broadcasting its media state.
939939+ const ctrl = getOrCreateRemoteControl(data.sessionId, data.deviceName);
940940+ ctrl.title = data.title || "";
941941+ ctrl.artist = data.artist || "";
942942+ ctrl.album = data.album || "";
943943+ ctrl.playbackState = data.playbackState || "none";
944944+ ctrl.duration = data.duration || 0;
945945+ ctrl.position = data.position || 0;
946946+ ctrl.hasMedia = data.playbackState !== "none";
947947+ updateMediaIcon();
948948+ },
949949+ });
940950941941- // When a paired peer connects (or reconnects), send a hello so active
942942- // players on the other side respond with their current state.
943943- // Delayed to allow the P2P channel sync to complete first.
951951+ mediaBroadcaster.resolveDeviceName();
952952+953953+ // When a paired peer connects, send hello after channel sync.
944954 navigator.embedder.pairing.addEventListener("peerjoined", () => {
945955 setTimeout(() => {
946946- mediaChannel.postMessage({ type: "hello" });
956956+ mediaBroadcaster.sendHello();
947957 }, 5000);
948958 });
949959950950- // Lazily resolve local device name.
951951- navigator.embedder.pairing
952952- ?.local()
953953- .then((info) => {
954954- localDeviceName = info.displayName;
955955- })
956956- .catch(() => {});
957957-958958- function broadcastMediaState(eventType, detail) {
959959- if (!localSessionId) return;
960960- // Merge into accumulated state.
961961- if (eventType === "metadata") {
962962- localMediaState.title = detail.title || "";
963963- localMediaState.artist = detail.artist || "";
964964- localMediaState.album = detail.album || "";
965965- } else if (eventType === "playbackstate") {
966966- localMediaState.playbackState = detail.playbackState || "none";
967967- } else if (eventType === "positionstate") {
968968- localMediaState.duration = detail.duration || 0;
969969- localMediaState.position = detail.position || 0;
970970- }
971971- mediaChannel.postMessage({
972972- type: "state",
973973- sessionId: localSessionId,
974974- deviceName: localDeviceName || "Unknown device",
975975- ...localMediaState,
976976- });
977977- }
978978-979960 function getOrCreateRemoteControl(sessionId, deviceName) {
980961 let ctrl = remoteMediaControls.get(sessionId);
981962 if (!ctrl) {
···984965 ctrl.deviceId = sessionId;
985966 ctrl.deviceName = deviceName;
986967 ctrl.hasMedia = true;
987987- ctrl.addEventListener("media-action", handleRemoteMediaAction);
968968+ ctrl.addEventListener("media-action", (event) => {
969969+ mediaBroadcaster.sendAction(event.detail.deviceId, event.detail.action);
970970+ });
988971 remoteMediaControls.set(sessionId, ctrl);
989972990990- // Insert into the media panel alongside local control.
973973+ // Insert into the media panel (desktop) or notification sheet (mobile).
991974 const panel = document.getElementById("media-panel");
992975 if (panel) {
993976 panel.appendChild(ctrl);
977977+ } else if (mobileNotificationSheet) {
978978+ // On mobile, insert after the local media control in the sheet's shadow DOM
979979+ const localCtrl = mobileNotificationSheet.shadowRoot?.querySelector("#mobile-media-control");
980980+ if (localCtrl) {
981981+ localCtrl.after(ctrl);
982982+ }
994983 }
995984 updateMediaIcon();
996985 }
···998987 return ctrl;
999988 }
100098910011001- function handleRemoteMediaMessage(data) {
10021002- if (data.type === "state") {
10031003- // Ignore our own broadcasts.
10041004- if (data.sessionId === localSessionId) return;
10051005-10061006- const ctrl = getOrCreateRemoteControl(data.sessionId, data.deviceName);
10071007- ctrl.title = data.title || "";
10081008- ctrl.artist = data.artist || "";
10091009- ctrl.album = data.album || "";
10101010- ctrl.playbackState = data.playbackState || "none";
10111011- ctrl.duration = data.duration || 0;
10121012- ctrl.position = data.position || 0;
10131013- ctrl.hasMedia = data.playbackState !== "none";
10141014- updateMediaIcon();
10151015- } else if (data.type === "action") {
10161016- // A remote device is requesting an action on our media session.
10171017- if (data.sessionId !== localSessionId) return;
10181018- if (mediaSessionWebviewId == null) return;
10191019- const entry = layoutManager.webviews.get(mediaSessionWebviewId);
10201020- if (entry) {
10211021- entry.webview.ensureIframe();
10221022- entry.webview.iframe.mediaSessionAction(data.action);
10231023- }
10241024- } else if (data.type === "hello") {
10251025- // A peer just joined — send our current state if we have an active session.
10261026- if (localSessionId && localMediaState.playbackState !== "none") {
10271027- mediaChannel.postMessage({
10281028- type: "state",
10291029- sessionId: localSessionId,
10301030- deviceName: localDeviceName || "Unknown device",
10311031- ...localMediaState,
10321032- });
10331033- }
10341034- }
10351035- }
10361036-10371037- function handleRemoteMediaAction(event) {
10381038- // Send the action back to the originating device via BroadcastChannel.
10391039- const msg = {
10401040- type: "action",
10411041- sessionId: event.detail.deviceId, // deviceId on the control is the sessionId
10421042- action: event.detail.action,
10431043- };
10441044- console.warn("[P2P Media] Sending action:", msg);
10451045- mediaChannel.postMessage(msg);
10461046- }
10471047-1048990 if (mediaIcon) {
1049991 mediaIcon.onclick = () => {
1050992 const panel = document.getElementById("media-panel");
···11001042 .addEventListener("webview-mediasession", (e) => {
11011043 const { webviewId, eventType } = e.detail;
11021044 mediaSessionWebviewId = webviewId;
11031103- // Generate a session ID on the first media event.
11041104- if (!localSessionId) {
11051105- localSessionId = `ms-${Date.now()}-${Math.random().toString(36).slice(2)}`;
11061106- }
11071045 updateMediaControl(mediaControl, eventType, e.detail);
11081046 // Also update mobile notification sheet's media control
11091047 if (mobileNotificationSheet) {
···11131051 updateMediaControl(mobileCtrl, eventType, e.detail);
11141052 }
11151053 // Broadcast to remote peers
11161116- broadcastMediaState(eventType, e.detail);
11171117- // Clear session ID when playback stops
11181118- if (eventType === "playbackstate" && e.detail.playbackState === "none") {
11191119- localSessionId = null;
11201120- localMediaState = {
11211121- title: "",
11221122- artist: "",
11231123- album: "",
11241124- playbackState: "none",
11251125- duration: 0,
11261126- position: 0,
11271127- };
11281128- }
10541054+ mediaBroadcaster.updateState(e.detail);
11291055 });
1130105611311057 function handleMediaAction(event) {
···11991125 const { peerId, url } = e.detail;
12001126 if (!peerId || !url) return;
1201112712021202- // Try both system app URLs since we don't know the remote platform.
11281128+ // Try all system app URLs since we don't know the remote platform.
12031129 const targetURLs = [
12041130 "beaver://system/index.html",
12051131 "beaver://system/index_mobile.html",
11321132+ "beaver://system/mediacenter/index.html",
12061133 ];
12071134 try {
12081135 await Promise.allSettled(
+115
ui/system/media_broadcast.js
···11+// SPDX-License-Identifier: AGPL-3.0-or-later
22+33+/**
44+ * P2P media session broadcast — shared between desktop and media center.
55+ * Uses BroadcastChannel("beaver-media-control") to sync media state
66+ * with paired peers and handle remote control actions.
77+ *
88+ * Protocol messages:
99+ * { type: "state", sessionId, deviceName, title, artist, album, playbackState, duration, position }
1010+ * { type: "action", sessionId, action }
1111+ * { type: "hello" }
1212+ */
1313+1414+export class MediaBroadcaster {
1515+ #channel = new BroadcastChannel("beaver-media-control");
1616+ #sessionId = null;
1717+ #deviceName = "Unknown device";
1818+ #state = {
1919+ title: "",
2020+ artist: "",
2121+ album: "",
2222+ playbackState: "none",
2323+ duration: 0,
2424+ position: 0,
2525+ };
2626+ #onRemoteAction; // (action: string) => void
2727+ #onRemoteState; // (data: object) => void — optional, for showing remote media
2828+2929+ /**
3030+ * @param {object} opts
3131+ * @param {string} opts.deviceName — display name for this device
3232+ * @param {(action: string) => void} opts.onRemoteAction — called when a peer sends a control action
3333+ * @param {(data: object) => void} [opts.onRemoteState] — called when a peer broadcasts its state
3434+ */
3535+ constructor({ deviceName, onRemoteAction, onRemoteState }) {
3636+ this.#deviceName = deviceName || "Unknown device";
3737+ this.#onRemoteAction = onRemoteAction;
3838+ this.#onRemoteState = onRemoteState;
3939+4040+ this.#channel.onmessage = (e) => this.#handleMessage(e.data);
4141+ }
4242+4343+ /** Resolve the device name lazily from the pairing API. */
4444+ async resolveDeviceName() {
4545+ try {
4646+ const info = await navigator.embedder.pairing.local();
4747+ this.#deviceName = info.displayName || this.#deviceName;
4848+ } catch {}
4949+ }
5050+5151+ /** Update local media state and broadcast to peers. */
5252+ updateState(detail) {
5353+ // Generate session ID on first media event
5454+ if (!this.#sessionId) {
5555+ this.#sessionId = `ms-${Date.now()}-${Math.random().toString(36).slice(2)}`;
5656+ }
5757+5858+ if (detail.title) this.#state.title = detail.title;
5959+ if (detail.artist) this.#state.artist = detail.artist;
6060+ if (detail.album) this.#state.album = detail.album;
6161+ if (detail.playbackState) this.#state.playbackState = detail.playbackState;
6262+ if (detail.duration) this.#state.duration = detail.duration;
6363+ if (detail.position) this.#state.position = detail.position;
6464+6565+ this.#broadcast();
6666+6767+ // Clear session on playback stop
6868+ if (detail.playbackState === "none") {
6969+ this.#sessionId = null;
7070+ this.#state = {
7171+ title: "", artist: "", album: "",
7272+ playbackState: "none", duration: 0, position: 0,
7373+ };
7474+ }
7575+ }
7676+7777+ /** Send a hello to discover active sessions on peers. */
7878+ sendHello() {
7979+ this.#channel.postMessage({ type: "hello" });
8080+ }
8181+8282+ /** Send a control action to a remote session. */
8383+ sendAction(sessionId, action) {
8484+ this.#channel.postMessage({ type: "action", sessionId, action });
8585+ }
8686+8787+ #broadcast() {
8888+ if (!this.#sessionId) return;
8989+ this.#channel.postMessage({
9090+ type: "state",
9191+ sessionId: this.#sessionId,
9292+ deviceName: this.#deviceName,
9393+ ...this.#state,
9494+ });
9595+ }
9696+9797+ #handleMessage(data) {
9898+ if (data.type === "hello") {
9999+ // Peer joined — respond with our state if active
100100+ if (this.#sessionId && this.#state.playbackState !== "none") {
101101+ this.#broadcast();
102102+ }
103103+ } else if (data.type === "action") {
104104+ // Peer wants to control our media
105105+ if (data.sessionId === this.#sessionId && this.#onRemoteAction) {
106106+ this.#onRemoteAction(data.action);
107107+ }
108108+ } else if (data.type === "state") {
109109+ // Peer is broadcasting its state — ignore our own
110110+ if (data.sessionId !== this.#sessionId && this.#onRemoteState) {
111111+ this.#onRemoteState(data);
112112+ }
113113+ }
114114+ }
115115+}