forked from
me.webbeef.org/browser.html
Rewild Your Web
1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3/**
4 * P2P media session broadcast — shared between desktop and media center.
5 * Uses BroadcastChannel("beaver-media-control") to sync media state
6 * with paired peers and handle remote control actions.
7 *
8 * Protocol messages:
9 * { type: "state", sessionId, deviceName, title, artist, album, playbackState, duration, position }
10 * { type: "action", sessionId, action }
11 * { type: "hello" }
12 */
13
14export class MediaBroadcaster {
15 #channel = new BroadcastChannel("beaver-media-control");
16 #sessionId = null;
17 #deviceName = "Unknown device";
18 #state = {
19 title: "",
20 artist: "",
21 album: "",
22 playbackState: "none",
23 duration: 0,
24 position: 0,
25 };
26 #onRemoteAction; // (action: string) => void
27 #onRemoteState; // (data: object) => void — optional, for showing remote media
28
29 /**
30 * @param {object} opts
31 * @param {string} opts.deviceName — display name for this device
32 * @param {(action: string) => void} opts.onRemoteAction — called when a peer sends a control action
33 * @param {(data: object) => void} [opts.onRemoteState] — called when a peer broadcasts its state
34 */
35 constructor({ deviceName, onRemoteAction, onRemoteState }) {
36 this.#deviceName = deviceName || "Unknown device";
37 this.#onRemoteAction = onRemoteAction;
38 this.#onRemoteState = onRemoteState;
39
40 this.#channel.onmessage = (e) => this.#handleMessage(e.data);
41 }
42
43 /** Resolve the device name lazily from the pairing API. */
44 async resolveDeviceName() {
45 try {
46 const info = await navigator.embedder.pairing.local();
47 this.#deviceName = info.displayName || this.#deviceName;
48 } catch {}
49 }
50
51 /** Update local media state and broadcast to peers. */
52 updateState(detail) {
53 // Generate session ID on first media event
54 if (!this.#sessionId) {
55 this.#sessionId = `ms-${Date.now()}-${Math.random().toString(36).slice(2)}`;
56 }
57
58 if (detail.title) this.#state.title = detail.title;
59 if (detail.artist) this.#state.artist = detail.artist;
60 if (detail.album) this.#state.album = detail.album;
61 if (detail.playbackState) this.#state.playbackState = detail.playbackState;
62 if (detail.duration) this.#state.duration = detail.duration;
63 if (detail.position) this.#state.position = detail.position;
64
65 this.#broadcast();
66
67 // Clear session on playback stop
68 if (detail.playbackState === "none") {
69 this.#sessionId = null;
70 this.#state = {
71 title: "", artist: "", album: "",
72 playbackState: "none", duration: 0, position: 0,
73 };
74 }
75 }
76
77 /** Send a hello to discover active sessions on peers. */
78 sendHello() {
79 this.#channel.postMessage({ type: "hello" });
80 }
81
82 /** Send a control action to a remote session. */
83 sendAction(sessionId, action) {
84 this.#channel.postMessage({ type: "action", sessionId, action });
85 }
86
87 #broadcast() {
88 if (!this.#sessionId) return;
89 this.#channel.postMessage({
90 type: "state",
91 sessionId: this.#sessionId,
92 deviceName: this.#deviceName,
93 ...this.#state,
94 });
95 }
96
97 #handleMessage(data) {
98 if (data.type === "hello") {
99 // Peer joined — respond with our state if active
100 if (this.#sessionId && this.#state.playbackState !== "none") {
101 this.#broadcast();
102 }
103 } else if (data.type === "action") {
104 // Peer wants to control our media
105 if (data.sessionId === this.#sessionId && this.#onRemoteAction) {
106 this.#onRemoteAction(data.action);
107 }
108 } else if (data.type === "state") {
109 // Peer is broadcasting its state — ignore our own
110 if (data.sessionId !== this.#sessionId && this.#onRemoteState) {
111 this.#onRemoteState(data);
112 }
113 }
114 }
115}