/**
* arozcast.js — Arozcast Sender SDK
*
* A lightweight, self-contained client library for ArozOS webapps that want
* to cast media to an Arozcast receiver screen.
*
* Include with a plain
*
* Then create one instance per cast session:
*
* const cast = new ArozCast({ aoRoot: ao_root });
* cast.on('status', s => console.log(s.currentTime));
*
* cast.ping('1234').then(exists => {
* if (exists) cast.connect('1234').then(() => {
* cast.load({ name: 'clip.mp4', type: 'video', src: url, filepath: vpath });
* cast.setVolume(videoEl.volume * 100, videoEl.muted);
* cast.play();
* });
* });
*
* See arozcast.md for the full reference.
*/
class ArozCast {
/**
* @param {object} [options]
* @param {string} [options.aoRoot] - ArozOS root URL (defaults to the
* global `ao_root` variable or '/').
* @param {number[]} [options.reconnectDelays] - Backoff delays in ms before each
* reconnection attempt. Default: [2000, 5000, 12000].
* Pass [] to disable auto-reconnect.
*/
constructor(options = {}) {
this._root = options.aoRoot
|| (typeof ao_root !== 'undefined' ? ao_root : '/');
this._delays = options.reconnectDelays !== undefined
? options.reconnectDelays
: [2000, 5000, 12000];
/** @type {string|null} Current room code, or null when not connected. */
this.code = null;
/** @type {boolean} True while the WebSocket is open. */
this.connected = false;
this._ws = null;
this._pingTimer = null;
this._watchTimer = null;
this._lastSeen = 0;
this._reconnectTimer = null;
this._reconnectCount = 0;
this._pendingCode = null; // code being retried
this._intentional = false; // true when WE are closing the socket on purpose
this._listeners = {}; // event → [handler, ...]
// ── Visibility accelerator ─────────────────────────────────────────
// When the phone comes back from sleep, retry reconnect immediately
// rather than waiting for the next scheduled attempt.
this._onVisibility = () => {
if (document.visibilityState === 'visible' && this._pendingCode) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
this._attemptReconnect();
}
};
document.addEventListener('visibilitychange', this._onVisibility);
// ── Page unload ────────────────────────────────────────────────────
// Close the WS silently — do NOT send media.stop so the receiver
// keeps playing after the sender tab is closed.
this._onUnload = () => this._abortWs();
window.addEventListener('beforeunload', this._onUnload);
// ── BroadcastChannel takeover ──────────────────────────────────────
// If another app (Movie, Photo, …) calls notifyTakeover(), this
// instance yields gracefully without reconnecting.
try {
this._bc = new BroadcastChannel('arozcast');
this._bc.onmessage = (e) => {
if (e.data && e.data.type === 'arozcast.takeover') {
this._intentional = true;
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
this._reconnectCount = 0;
this._pendingCode = null;
this._abortWs();
this._emit('takeover', {});
}
};
} catch (_) {
this._bc = null;
}
}
// ══════════════════════════════════════════════════════════════════════
// Core lifecycle
// ══════════════════════════════════════════════════════════════════════
/**
* Connect to an existing Arozcast room.
*
* - Cancels any pending auto-reconnect to the previous room.
* - Broadcasts an `arozcast.takeover` signal so other apps (Musicify,
* Photo, Movie) release their session.
* - Sends `peer.hello` and starts the heartbeat on success.
*
* @param {string} code - 4-digit room code shown by the Arozcast receiver.
* @returns {Promise} Resolves when the socket is open and announced.
* Rejects if the server refuses the connection or
* the 8-second handshake times out.
*/
connect(code) {
// Cancel any pending auto-reconnect to the old room
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
this._reconnectCount = 0;
this._pendingCode = null;
this._intentional = false;
// Silence the old socket's handlers before replacing it
this._abortWs();
// Tell other sender apps to yield their sessions
this.notifyTakeover();
return this._openWs(code);
}
/**
* Explicitly disconnect from the current room.
*
* - Sends `media.stop` so the receiver clears its screen.
* - Cancels any pending auto-reconnect.
* - Does NOT close the room itself (the Arozcast receiver owns the room).
*/
disconnect() {
this._intentional = true;
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
this._reconnectCount = 0;
this._pendingCode = null;
if (this.isConnected()) this.stop(); // tell receiver to clear its screen
this._abortWs();
this.code = null;
this.connected = false;
}
/**
* Release all resources held by this instance.
*
* Removes the `visibilitychange` and `beforeunload` listeners, closes the
* BroadcastChannel, and closes the WebSocket silently (no `media.stop`).
* Call this if you no longer need the instance but the page is still open
* (e.g. when unmounting a UI component). For page unload, `beforeunload`
* handles cleanup automatically.
*/
destroy() {
this._intentional = true;
clearTimeout(this._reconnectTimer);
document.removeEventListener('visibilitychange', this._onVisibility);
window.removeEventListener('beforeunload', this._onUnload);
if (this._bc) { this._bc.close(); this._bc = null; }
this._abortWs();
this._listeners = {};
}
/**
* Check whether a room exists before connecting.
*
* @param {string} code - 4-digit room code.
* @returns {Promise} true if the room is active.
*/
async ping(code) {
const res = await fetch(this._root + 'api/arozcast/ping?code=' + code);
const data = await res.json();
return !!data.exists;
}
/**
* Signal other ArozOS apps to yield their cast sessions.
*
* Called automatically by `connect()`. Call manually if your app takes
* over a session through a path that bypasses `connect()`.
*/
notifyTakeover() {
try {
new BroadcastChannel('arozcast').postMessage({ type: 'arozcast.takeover' });
} catch (_) {}
}
/** @returns {boolean} true when the WebSocket is open and ready. */
isConnected() {
return !!(this._ws && this._ws.readyState === WebSocket.OPEN);
}
// ══════════════════════════════════════════════════════════════════════
// Event emitter
// ══════════════════════════════════════════════════════════════════════
/**
* Register an event listener. Returns `this` for chaining.
*
* | Event | Payload fields |
* |----------------|-----------------------------------------------------------|
* | `connect` | `{ code }` |
* | `disconnect` | `{ code }` |
* | `reconnecting` | `{ code, attempt, delay }` |
* | `giveup` | `{ code }` |
* | `takeover` | `{}` — another app claimed the cast session |
* | `status` | `{ currentTime, duration, isPlaying, volume, isMuted }` |
* | `ended` | `{}` — current media finished on the receiver |
*
* @param {string} event
* @param {Function} handler
* @returns {ArozCast}
*/
on(event, handler) {
(this._listeners[event] = this._listeners[event] || []).push(handler);
return this;
}
/**
* Remove a previously registered listener. Returns `this` for chaining.
* @param {string} event
* @param {Function} handler
* @returns {ArozCast}
*/
off(event, handler) {
if (this._listeners[event]) {
this._listeners[event] = this._listeners[event].filter(h => h !== handler);
}
return this;
}
// ══════════════════════════════════════════════════════════════════════
// Playback command helpers
// ══════════════════════════════════════════════════════════════════════
/**
* Load a new media item on the receiver and start playing from the
* optional start position.
*
* **Send order matters:** always follow with `setVolume()` then `play()` or
* `pause()` so the receiver has the right volume before decoding begins.
*
* @param {object} track
* @param {string} track.name Display name shown in the receiver toolbar.
* @param {string} track.type `'audio'` | `'video'` | `'photo'`
* @param {string} track.src Full playback URL. For formats that may not be
* natively playable in the browser, use the ArozOS
* transcoding API URL instead of the raw file URL.
* @param {string} track.filepath ArozOS virtual path (used by receiver for album art).
* @param {number} [track.startTime] Position in seconds to seek to before playback.
* @param {string} [track.artist] Artist label (audio mode only).
* @param {string} [track.cover] Cover image URL override (audio mode only).
*/
load(track) { this.send('media.load', track); }
/** Resume or start playback on the receiver. */
play() { this.send('media.play', {}); }
/** Pause playback on the receiver. */
pause() { this.send('media.pause', {}); }
/**
* Seek to an absolute position.
* For skip buttons, prefer `seekRel()` — it avoids stale-base accumulation
* when the user presses the button faster than `status` events arrive.
* @param {number} time Position in seconds.
*/
seek(time) { this.send('media.seek', { time }); }
/**
* Seek by a relative delta. The receiver applies the delta to its **live**
* `currentTime`, so rapid key presses accumulate correctly without waiting
* for a `status` update round-trip.
* @param {number} delta Seconds to skip. Negative = rewind.
*/
seekRel(delta) { this.send('media.seekrel', { delta }); }
/**
* Set volume and mute state on the receiver.
*
* > **Scale:** Arozcast uses **0–100**. If your local `