Przeglądaj źródła

Add Arozcast sender SDK, docs, and examples

Introduce arozcast.js — a self-contained ArozCast Sender SDK for ArozOS webapps that handles WebSocket lifecycle, heartbeating, watchdog, backoff reconnection, and cross-tab takeover via BroadcastChannel. Adds comprehensive reference documentation (arozcast.md) and several example pages demonstrating audio, video and photo casting (audio-player.html, audio.html, photo.html, utilities.html, video-player.html, video.html). Provides high-level playback helpers (load/play/pause/seek/setVolume/setRepeat/stop), event hooks (connect/disconnect/reconnecting/giveup/takeover/status/ended), and utilities like ping(), notifyTakeover(), connect/disconnect/destroy to simplify integration.
Toby Chui 1 tydzień temu
rodzic
commit
ee81cedb4c

+ 544 - 0
src/web/Arozcast/arozcast.js

@@ -0,0 +1,544 @@
+/**
+ * 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 <script> tag — no build tools or module system needed:
+ *
+ *   <script src="../../Arozcast/arozcast.js"></script>
+ *
+ * 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<void>}      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<boolean>}      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 `<video>` / `<audio>`
+     * > element uses 0–1 (the HTML default), multiply by 100 before calling.
+     *
+     * @param {number}  level Volume level, 0–100.
+     * @param {boolean} muted Whether audio should be muted.
+     */
+    setVolume(level, muted) { this.send('media.volume',  { volume: level, muted: !!muted }); }
+
+    /**
+     * Sync the repeat mode to the receiver.
+     *
+     * | mode     | Receiver behaviour                                               |
+     * |----------|------------------------------------------------------------------|
+     * | `'none'` | No looping.                                                      |
+     * | `'one'`  | Sets `loop = true` on the media element (browser handles it).   |
+     * | `'all'`  | Visual indicator only — your app must listen for the `ended`    |
+     * |          | event and call `load()` with the next track.                     |
+     *
+     * @param {'none'|'one'|'all'} mode
+     */
+    setRepeat(mode)         { this.send('media.repeat',  { mode });                       }
+
+    /**
+     * Stop playback and clear the receiver's screen.
+     * Only call this when the user **explicitly** disconnects. Do not call it
+     * on page unload — let the receiver keep playing.
+     */
+    stop()                  { this.send('media.stop',    {});                             }
+
+    /**
+     * Send a raw message to the room. Silently dropped if the socket is not open.
+     * @param {string}  topic
+     * @param {object} [payload]
+     */
+    send(topic, payload) {
+        if (this.isConnected()) {
+            this._ws.send(JSON.stringify({ topic, payload: payload || {} }));
+        }
+    }
+
+    // ══════════════════════════════════════════════════════════════════════
+    //  Internals
+    // ══════════════════════════════════════════════════════════════════════
+
+    /** Fire all handlers registered for `event`. */
+    _emit(event, detail) {
+        (this._listeners[event] || []).forEach(h => {
+            try { h(detail); } catch (_) {}
+        });
+    }
+
+    /**
+     * Open a fresh WebSocket to `code`. Returns a Promise that resolves once
+     * `peer.hello` is sent, or rejects if the connection fails / times out.
+     * @private
+     */
+    _openWs(code) {
+        const self = this;
+        return new Promise((resolve, reject) => {
+            const wsUrl = new URL(
+                self._root + 'api/arozcast/ws?code=' + code,
+                window.location.href
+            );
+            wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+
+            const ws  = new WebSocket(wsUrl.toString());
+            let settled = false;
+
+            // 8-second handshake timeout
+            const timeout = setTimeout(() => {
+                ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null;
+                ws.close();
+                if (!settled) { settled = true; reject(new Error('Connection timed out')); }
+            }, 8000);
+
+            ws.onerror = () => {}; // onerror always precedes onclose — handled there
+
+            ws.onmessage = (evt) => {
+                self._lastSeen = Date.now();
+                try { self._handleIncoming(JSON.parse(evt.data)); } catch (_) {}
+            };
+
+            // This initial onclose only fires when the connect itself fails.
+            // Once onopen has run, we replace it with the reconnect handler.
+            ws.onclose = () => {
+                clearTimeout(timeout);
+                if (!settled) { settled = true; reject(new Error('Connection failed')); }
+            };
+
+            ws.onopen = () => {
+                clearTimeout(timeout);
+                settled         = true;
+                self._ws        = ws;
+                self.code       = code;
+                self.connected  = true;
+                self._lastSeen  = Date.now();
+                self._intentional = false;
+
+                // Announce presence and start heartbeat
+                self.send('peer.hello', {});
+                self._startTimers();
+                self._emit('connect', { code });
+
+                // Replace the connect-phase onclose with the live reconnect handler
+                ws.onclose = () => self._onDrop(code);
+
+                resolve();
+            };
+        });
+    }
+
+    /**
+     * Called when an established connection drops unexpectedly.
+     * Schedules auto-reconnect unless the close was intentional.
+     * @private
+     */
+    _onDrop(code) {
+        this._stopTimers();
+        this.connected = false;
+        this._ws       = null;
+        if (!this._intentional) {
+            this._pendingCode = code;
+            this._emit('disconnect', { code });
+            this._scheduleReconnect();
+        }
+    }
+
+    /**
+     * Schedule the next reconnection attempt with the appropriate backoff delay.
+     * @private
+     */
+    _scheduleReconnect() {
+        if (!this._delays.length || this._reconnectCount >= this._delays.length) {
+            this._reconnectCount = 0;
+            this._pendingCode    = null;
+            this._emit('giveup', { code: this.code });
+            return;
+        }
+        const delay = this._delays[this._reconnectCount++];
+        this._emit('reconnecting', { code: this._pendingCode, attempt: this._reconnectCount, delay });
+        this._reconnectTimer = setTimeout(() => {
+            this._reconnectTimer = null;
+            this._attemptReconnect();
+        }, delay);
+    }
+
+    /**
+     * Attempt a single reconnection to `_pendingCode`.
+     * On success: announces peer.hello and re-syncs volume/repeat (caller's
+     * responsibility via the `connect` event). Does NOT resend `media.load` —
+     * Arozcast kept playing while the sender was gone.
+     * @private
+     */
+    _attemptReconnect() {
+        const code = this._pendingCode;
+        if (!code) return;
+        const self = this;
+
+        const wsUrl = new URL(
+            this._root + 'api/arozcast/ws?code=' + code,
+            window.location.href
+        );
+        wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+
+        const ws = new WebSocket(wsUrl.toString());
+
+        // 8-second timeout per attempt
+        const timeout = setTimeout(() => {
+            ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null;
+            ws.close();
+            self._scheduleReconnect();
+        }, 8000);
+
+        ws.onerror = () => {};
+
+        ws.onmessage = (evt) => {
+            self._lastSeen = Date.now();
+            try { self._handleIncoming(JSON.parse(evt.data)); } catch (_) {}
+        };
+
+        ws.onclose = () => {
+            clearTimeout(timeout);
+            self._scheduleReconnect();
+        };
+
+        ws.onopen = () => {
+            clearTimeout(timeout);
+            self._reconnectCount = 0;
+            self._pendingCode    = null;
+            self._ws             = ws;
+            self.code            = code;
+            self.connected       = true;
+            self._lastSeen       = Date.now();
+            self._intentional    = false;
+
+            // Re-announce — do NOT resend media.load; Arozcast kept playing
+            self.send('peer.hello', {});
+            self._startTimers();
+            self._emit('connect', { code });
+
+            ws.onclose = () => self._onDrop(code);
+        };
+    }
+
+    /**
+     * Dispatch incoming receiver messages to the event system.
+     * Only `status.update` and `media.ended` are receiver-originated.
+     * All other topics are sender-originated and should not appear here.
+     * @private
+     */
+    _handleIncoming(msg) {
+        switch (msg.topic) {
+            case 'status.update': this._emit('status', msg.payload); break;
+            case 'media.ended':   this._emit('ended',  msg.payload); break;
+            // Unknown/loopback topics are silently ignored
+        }
+    }
+
+    /**
+     * Start the heartbeat interval (5 s) and the watchdog interval (4 s).
+     * The watchdog closes the socket if no message has arrived in 12 s,
+     * triggering the reconnect flow.
+     * @private
+     */
+    _startTimers() {
+        this._stopTimers();
+        this._pingTimer = setInterval(() => {
+            this.send('peer.heartbeat', {});
+        }, 5000);
+        this._watchTimer = setInterval(() => {
+            if (Date.now() - this._lastSeen > 12000 && this._ws) {
+                this._ws.close();
+            }
+        }, 4000);
+    }
+
+    /** @private */
+    _stopTimers() {
+        clearInterval(this._pingTimer);
+        clearInterval(this._watchTimer);
+        this._pingTimer  = null;
+        this._watchTimer = null;
+    }
+
+    /**
+     * Silently close the current WebSocket, nulling all handlers first so
+     * the close does not trigger reconnection.
+     * @private
+     */
+    _abortWs() {
+        this._stopTimers();
+        if (this._ws) {
+            const old = this._ws;
+            old.onopen = old.onclose = old.onerror = old.onmessage = null;
+            old.close();
+            this._ws = null;
+        }
+        this.connected = false;
+    }
+}

+ 703 - 0
src/web/Arozcast/arozcast.md

@@ -0,0 +1,703 @@
+# arozcast.js — SDK Reference
+
+`arozcast.js` is a ready-made JavaScript client for ArozOS webapps that want to send media to an Arozcast receiver screen. It wraps the raw WebSocket API (documented in `README.md`) and handles connection management, heartbeating, exponential-backoff reconnection, and cross-tab coordination so you don't have to.
+
+---
+
+## Table of Contents
+
+1. [Setup](#setup)
+2. [Quick Start](#quick-start)
+3. [Constructor](#constructor)
+4. [Lifecycle Methods](#lifecycle-methods)
+   - [connect()](#connectcode)
+   - [disconnect()](#disconnect)
+   - [destroy()](#destroy)
+   - [ping()](#pingcode)
+   - [notifyTakeover()](#notifytakeover)
+   - [isConnected()](#isconnected)
+5. [Playback Commands](#playback-commands)
+   - [load()](#loadtrack)
+   - [play()](#play)
+   - [pause()](#pause)
+   - [seek()](#seektime)
+   - [seekRel()](#seekreldelta)
+   - [setVolume()](#setvolumelevel-muted)
+   - [setRepeat()](#setrepeatmode)
+   - [stop()](#stop)
+   - [send()](#sendtopic-payload)
+6. [Events](#events)
+7. [Properties](#properties)
+8. [Integration Patterns](#integration-patterns)
+   - [Basic audio player](#basic-audio-player)
+   - [Video player with seek bar](#video-player-with-seek-bar)
+   - [Playlist with repeat modes](#playlist-with-repeat-modes)
+   - [Reconnect UI feedback](#reconnect-ui-feedback)
+   - [Handing off to another app](#handing-off-to-another-app)
+9. [Lifecycle Diagram](#lifecycle-diagram)
+10. [Comparison: SDK vs Raw API](#comparison-sdk-vs-raw-api)
+
+---
+
+## Setup
+
+Copy `arozcast.js` from `src/web/Arozcast/` into your webapp, or reference it with a relative path:
+
+```html
+<!-- From your webapp's HTML -->
+<script src="../../Arozcast/arozcast.js"></script>
+```
+
+No bundler or npm install required. The class is attached to `window.ArozCast` after the script loads.
+
+---
+
+## Quick Start
+
+```javascript
+// 1. Create one instance for your app's cast session
+const cast = new ArozCast({ aoRoot: ao_root });
+
+// 2. Listen for events
+cast
+  .on('connect',      ({ code })  => updateUI('Connected to room ' + code))
+  .on('disconnect',   ({ code })  => updateUI('Lost connection — retrying…'))
+  .on('giveup',       ({ code })  => resumeLocally())
+  .on('status',       (s)        => updateProgress(s.currentTime, s.duration))
+  .on('ended',        ()         => playNextTrack());
+
+// 3. Check the room is alive, then connect
+const code = prompt('Enter Arozcast room code:');
+cast.ping(code).then(exists => {
+    if (!exists) { alert('Room not found'); return; }
+
+    cast.connect(code).then(() => {
+        // 4. Send the current media item
+        const vid = document.getElementById('my-video');
+        cast.load({
+            name:      currentEpisode.name,
+            type:      'video',
+            src:       currentEpisode.streamUrl,   // full playback URL
+            filepath:  currentEpisode.filepath,    // ArozOS virtual path
+            startTime: vid.currentTime             // sync mid-playback
+        });
+        cast.setVolume(vid.volume * 100, vid.muted);
+        cast.play();
+
+        vid.pause(); // local video hands off to receiver
+    });
+});
+
+// 5. Explicit user disconnect
+document.getElementById('disconnect-btn').addEventListener('click', () => {
+    const pos = /* save current position from status event */;
+    cast.disconnect();
+    resumeLocallyAt(pos);
+});
+```
+
+---
+
+## Constructor
+
+```javascript
+const cast = new ArozCast(options);
+```
+
+| Option             | Type       | Default                | Description |
+|--------------------|------------|------------------------|-------------|
+| `aoRoot`           | `string`   | global `ao_root` or `/` | ArozOS root URL. Pass `ao_root` (set by `ao_module.js`) when available. |
+| `reconnectDelays`  | `number[]` | `[2000, 5000, 12000]`  | Millisecond delays before each reconnection attempt. Three entries = three attempts. Pass `[]` to disable auto-reconnect entirely. |
+
+```javascript
+// Custom backoff (5 attempts)
+const cast = new ArozCast({
+    aoRoot:          ao_root,
+    reconnectDelays: [1000, 3000, 8000, 15000, 30000],
+});
+
+// Auto-reconnect disabled
+const cast = new ArozCast({ aoRoot: ao_root, reconnectDelays: [] });
+```
+
+---
+
+## Lifecycle Methods
+
+### `connect(code)`
+
+Connects to an existing Arozcast room and announces the sender.
+
+- Cancels any pending auto-reconnect to the previous room.
+- Broadcasts `arozcast.takeover` so other open sender apps (Musicify, Movie, Photo) release their sessions.
+- Sends `peer.hello` and starts the heartbeat on success.
+
+```javascript
+cast.connect('1234')
+    .then(() => { /* socket open, peer.hello sent */ })
+    .catch(err => console.error('Failed to connect:', err.message));
+```
+
+**Returns:** `Promise<void>` — resolves when the WebSocket is open, rejects if the server refuses the connection or the 8-second handshake times out.
+
+> After `connect()` resolves, always send `load()`, `setVolume()`, then `play()` in that order.
+
+---
+
+### `disconnect()`
+
+Explicitly disconnects from the current room.
+
+- Sends `media.stop` to clear the receiver's screen.
+- Cancels any pending auto-reconnect.
+- Sets `cast.connected = false` and `cast.code = null`.
+
+Use this when the **user** actively chooses to stop casting (e.g. clicks "Stop Cast"). Do **not** call this on page unload — use `destroy()` or let `beforeunload` handle it automatically.
+
+```javascript
+function stopCasting() {
+    const resumeAt = lastStatusTime; // saved from 'status' event
+    cast.disconnect();
+    resumeLocalVideoAt(resumeAt);
+}
+```
+
+---
+
+### `destroy()`
+
+Releases all resources held by this instance without notifying the receiver.
+
+- Removes `visibilitychange` and `beforeunload` listeners added in the constructor.
+- Closes the BroadcastChannel.
+- Closes the WebSocket silently — **does not** send `media.stop`, so the receiver keeps playing.
+
+Called automatically on `beforeunload`. Call it manually only if you need to tear down the instance while the page is still open (e.g. unmounting a component).
+
+```javascript
+// Clean up when navigating away within a SPA
+onRouteChange(() => cast.destroy());
+```
+
+---
+
+### `ping(code)`
+
+Checks whether a room with the given code is currently active.
+
+```javascript
+const exists = await cast.ping('1234');
+if (exists) { /* safe to connect */ }
+```
+
+**Returns:** `Promise<boolean>`
+
+Call this before `connect()` to give the user a clear error message instead of a silent timeout.
+
+---
+
+### `notifyTakeover()`
+
+Broadcasts `arozcast.takeover` on the `BroadcastChannel('arozcast')` channel, signalling all other open sender tabs to release their sessions.
+
+Called automatically by `connect()`. Call it manually if your app starts a new session through a path that bypasses `connect()` (e.g. a deep-link handler).
+
+```javascript
+// Taking over from a non-SDK code path
+myLegacyCastWs = new WebSocket(url);
+cast.notifyTakeover(); // silence other senders
+```
+
+---
+
+### `isConnected()`
+
+Returns `true` when the WebSocket is open and ready to accept messages.
+
+```javascript
+if (cast.isConnected()) {
+    cast.seek(42);
+} else {
+    myVideo.currentTime = 42;
+}
+```
+
+Equivalent to checking `cast.connected`, but safe to call at any time.
+
+---
+
+## Playback Commands
+
+All commands are silently dropped if the socket is not open. There is no queue — if you need to wait, check `isConnected()` first.
+
+---
+
+### `load(track)`
+
+Loads a new media item on the receiver. The receiver starts playing automatically after this call; always follow with `setVolume()` then `play()` or `pause()` to set the correct volume and play state before the browser starts decoding.
+
+```javascript
+cast.load({
+    name:      'Episode 3.mp4',   // displayed in receiver toolbar
+    type:      'video',           // 'audio' | 'video' | 'photo'
+    src:       streamUrl,         // full URL the receiver's <video> will load
+    filepath:  '/Videos/ep3.mp4', // ArozOS vpath for album art
+    startTime: 120,               // seek to 2 min before play (optional)
+});
+cast.setVolume(myVideo.volume * 100, myVideo.muted);
+cast.play();
+```
+
+| Field        | Required | Description |
+|--------------|----------|-------------|
+| `name`       | ✓        | Display name in the receiver toolbar |
+| `type`       | ✓        | `'audio'`, `'video'`, or `'photo'` |
+| `src`        | ✓        | Playback URL. Use the ArozOS transcoding API (`/media/transcode?file=…`) for formats the browser may not natively support. |
+| `filepath`   | ✓        | ArozOS virtual path — receiver uses it to load album art |
+| `startTime`  |          | Seconds to seek to before playback (default: 0) |
+| `artist`     |          | Artist subtitle (audio mode) |
+| `cover`      |          | Cover art URL override (audio mode) |
+
+---
+
+### `play()`
+
+Resumes or starts playback.
+
+```javascript
+cast.play();
+```
+
+---
+
+### `pause()`
+
+Pauses playback.
+
+```javascript
+cast.pause();
+```
+
+---
+
+### `seek(time)`
+
+Seeks to an absolute position in seconds.
+
+```javascript
+// User clicked on the progress bar
+cast.seek(progressBar.ratio * duration);
+```
+
+For **skip buttons** (e.g. ±10 s), prefer `seekRel()` — it avoids stale-base accumulation when the button is tapped faster than `status` events arrive.
+
+---
+
+### `seekRel(delta)`
+
+Seeks by a relative delta in seconds. The receiver applies the delta to its **live** `currentTime`, so rapid presses stack correctly without waiting for a `status` round-trip.
+
+```javascript
+// +10 s with optimistic local UI update
+cast.seekRel(10);
+localDisplayTime = Math.min(localDuration, localDisplayTime + 10);
+updateProgressBar();
+```
+
+Negative values rewind:
+
+```javascript
+cast.seekRel(-10); // rewind 10 s
+```
+
+---
+
+### `setVolume(level, muted)`
+
+Sets volume and mute state on the receiver.
+
+> **Scale:** Arozcast uses **0–100**. HTML `<video>` / `<audio>` uses **0–1**. Multiply by 100 when passing the element's `.volume` property.
+
+```javascript
+// Sync local element's volume to receiver
+cast.setVolume(myVideo.volume * 100, myVideo.muted);
+```
+
+---
+
+### `setRepeat(mode)`
+
+Syncs the repeat mode to the receiver.
+
+| `mode`   | Receiver behaviour |
+|----------|--------------------|
+| `'none'` | No looping |
+| `'one'`  | Sets `loop = true` on the media element — browser handles it natively, no `ended` event fires |
+| `'all'`  | Shows a visual indicator only — your app must listen for the `ended` event and call `load()` with the next track |
+
+```javascript
+cast.setRepeat('one');  // loop this track
+cast.setRepeat('all');  // playlist loop — handle via 'ended' event
+cast.setRepeat('none'); // no repeat
+```
+
+Send on: initial `connect()`, when the user changes repeat mode, and inside the `connect` event handler (for reconnects).
+
+---
+
+### `stop()`
+
+Stops playback and clears the receiver's screen. The receiver returns to its idle/waiting state.
+
+```javascript
+cast.stop();
+```
+
+**Only call this when the user explicitly disconnects.** Do not call it on page unload or when the WebSocket drops — the receiver should keep playing while the sender reconnects.
+
+`disconnect()` calls `stop()` internally, so you normally don't need to call it directly.
+
+---
+
+### `send(topic, payload)`
+
+Send a raw message to the room. Use this for custom topics or for topics not covered by the helpers above.
+
+```javascript
+cast.send('media.seekrel', { delta: -30 });
+cast.send('my.custom.topic', { data: 'hello' });
+```
+
+Silently dropped if `isConnected()` is false.
+
+---
+
+## Events
+
+Register handlers with `.on(event, handler)`. A handler can be removed with `.off(event, handler)`. Both methods return `this` for chaining.
+
+```javascript
+cast
+    .on('connect',      handler)
+    .on('disconnect',   handler)
+    .on('giveup',       handler);
+```
+
+---
+
+### `connect` — `{ code }`
+
+Fired when the WebSocket is successfully opened. This fires for both the **initial** connection and every successful **reconnection**.
+
+On reconnect, re-sync state to the receiver — send `setVolume()` and `setRepeat()`. Do **not** resend `load()` — Arozcast kept playing.
+
+```javascript
+cast.on('connect', ({ code }) => {
+    showToast('Connected to room ' + code);
+
+    // Re-sync state on reconnect
+    cast.setVolume(myVideo.volume * 100, myVideo.muted);
+    cast.setRepeat(currentRepeatMode);
+    // Do NOT call cast.load() here — Arozcast kept playing
+});
+```
+
+---
+
+### `disconnect` — `{ code }`
+
+Fired when the WebSocket drops unexpectedly (phone sleep, network error, proxy timeout). Auto-reconnect will begin according to `reconnectDelays`. Update your UI to indicate the reconnection attempt.
+
+```javascript
+cast.on('disconnect', ({ code }) => {
+    showToast('Connection lost — reconnecting…');
+    disableSeekBar();
+});
+```
+
+---
+
+### `reconnecting` — `{ code, attempt, delay }`
+
+Fired just before each reconnection attempt is scheduled. Useful for showing a countdown or attempt counter.
+
+```javascript
+cast.on('reconnecting', ({ code, attempt, delay }) => {
+    showToast(`Reconnecting… attempt ${attempt} in ${delay / 1000}s`);
+});
+```
+
+---
+
+### `giveup` — `{ code }`
+
+Fired when all reconnection attempts are exhausted. Resume local playback.
+
+```javascript
+cast.on('giveup', ({ code }) => {
+    showToast('Could not reconnect — resuming locally');
+    resumeLocally(lastKnownTime);
+});
+```
+
+---
+
+### `takeover` — `{}`
+
+Fired when another sender app has claimed the Arozcast session. The current instance's WebSocket is closed and no reconnection is attempted. Clean up your cast UI.
+
+```javascript
+cast.on('takeover', () => {
+    updateCastButton('inactive');
+});
+```
+
+---
+
+### `status` — `{ currentTime, duration, isPlaying, volume, isMuted }`
+
+Fired every ~3 seconds with the receiver's live playback state. Use this to keep your sender-side progress bar in sync.
+
+```javascript
+let lastStatus = null;
+cast.on('status', s => {
+    lastStatus = s;
+    updateProgressBar(s.currentTime, s.duration);
+    updatePlayIcon(s.isPlaying);
+});
+
+// On reconnect, wait for the next status event (arrives within 3 s)
+// instead of pushing your local stale time to the receiver.
+```
+
+---
+
+### `ended` — `{}`
+
+Fired when the current media track finishes playing on the receiver. Handle this to advance a playlist when `repeatMode === 'all'`.
+
+```javascript
+cast.on('ended', () => {
+    if (repeatMode === 'all') {
+        const next = getNextTrack();
+        cast.load({ ...next });
+        cast.setVolume(volume, muted);
+        cast.play();
+    }
+});
+```
+
+> **Note:** When `repeat === 'one'`, the receiver sets `loop = true` on its media element. The browser loops natively and `ended` is never fired.
+
+---
+
+## Properties
+
+| Property    | Type          | Description |
+|-------------|---------------|-------------|
+| `code`      | `string\|null` | The current room code, or `null` when not connected |
+| `connected` | `boolean`     | `true` while the WebSocket is open |
+
+---
+
+## Integration Patterns
+
+### Basic audio player
+
+```javascript
+const cast = new ArozCast({ aoRoot: ao_root });
+let lastTime = 0;
+
+cast.on('status', s => { lastTime = s.currentTime; });
+cast.on('giveup', () => {
+    audio.currentTime = lastTime;
+    if (wasPlaying) audio.play();
+});
+cast.on('connect', () => {
+    cast.setVolume(audio.volume * 100, audio.muted);
+    cast.setRepeat(repeatMode);
+});
+
+async function startCast(code) {
+    if (!(await cast.ping(code))) { showError('Room not found'); return; }
+    await cast.connect(code);
+    cast.load({
+        name:      currentTrack.name,
+        type:      'audio',
+        src:       ao_root + 'media?file=' + encodeURIComponent(currentTrack.filepath),
+        filepath:  currentTrack.filepath,
+        artist:    currentTrack.artist,
+        startTime: audio.currentTime,
+    });
+    cast.setVolume(audio.volume * 100, audio.muted);
+    cast.setRepeat(repeatMode);
+    wasPlaying = !audio.paused;
+    cast[wasPlaying ? 'play' : 'pause']();
+    audio.pause();
+}
+```
+
+---
+
+### Video player with seek bar
+
+```javascript
+cast.on('status', s => {
+    // Authoritative sync from receiver (~3 s interval)
+    castTime     = s.currentTime;
+    castDuration = s.duration;
+    castPlaying  = s.isPlaying;
+    updateSeekBar(castTime, castDuration);
+});
+
+// Seek bar click — use absolute seek
+seekBar.addEventListener('click', e => {
+    if (!cast.isConnected()) { vid.currentTime = e.ratio * vid.duration; return; }
+    const t = e.ratio * castDuration;
+    cast.seek(t);
+    castTime = t;          // optimistic update
+    updateSeekBar(castTime, castDuration);
+});
+
+// Keyboard skip — use relative seek for rapid key presses
+document.addEventListener('keydown', e => {
+    if (e.key === 'ArrowRight') {
+        cast.seekRel(10);
+        castTime = Math.min(castDuration, castTime + 10); // optimistic
+        updateSeekBar(castTime, castDuration);
+    }
+    if (e.key === 'ArrowLeft') {
+        cast.seekRel(-10);
+        castTime = Math.max(0, castTime - 10);
+        updateSeekBar(castTime, castDuration);
+    }
+});
+
+// Play / pause — optimistic UI
+playBtn.addEventListener('click', () => {
+    if (cast.isConnected()) {
+        if (castPlaying) { cast.pause(); castPlaying = false; }
+        else             { cast.play();  castPlaying = true;  }
+        updatePlayIcon(castPlaying);
+    } else {
+        vid.paused ? vid.play() : vid.pause();
+    }
+});
+```
+
+---
+
+### Playlist with repeat modes
+
+```javascript
+let repeatMode = 'none'; // 'none' | 'one' | 'all'
+
+function cycleRepeat() {
+    const modes = ['none', 'all', 'one'];
+    repeatMode = modes[(modes.indexOf(repeatMode) + 1) % modes.length];
+    if (cast.isConnected()) cast.setRepeat(repeatMode);
+    updateRepeatIcon(repeatMode);
+}
+
+// Advance playlist when the receiver finishes the track
+cast.on('ended', () => {
+    // repeatMode 'one' never fires ended (receiver loops natively)
+    if (repeatMode === 'all') {
+        loadTrack(nextTrackIndex());
+    } else {
+        // repeatMode 'none' — stop after last track
+        if (hasNextTrack()) loadTrack(nextTrackIndex());
+    }
+});
+
+function loadTrack(index) {
+    const t = playlist[index];
+    cast.load({ name: t.name, type: 'audio', src: t.url, filepath: t.path });
+    cast.setVolume(volume, muted);
+    cast.play();
+    currentIndex = index;
+}
+```
+
+---
+
+### Reconnect UI feedback
+
+```javascript
+const toast = document.getElementById('cast-toast');
+
+cast
+    .on('connect',      ({ code }) => {
+        toast.textContent = 'Casting to room ' + code;
+        toast.className   = 'toast connected';
+    })
+    .on('disconnect',   () => {
+        toast.textContent = 'Connection lost — reconnecting…';
+        toast.className   = 'toast reconnecting';
+    })
+    .on('reconnecting', ({ attempt, delay }) => {
+        toast.textContent = `Reconnecting (attempt ${attempt})…`;
+    })
+    .on('giveup', () => {
+        toast.textContent = 'Cast ended — playing locally';
+        toast.className   = 'toast';
+        resumeLocally();
+    });
+```
+
+---
+
+### Handing off to another app
+
+When the user switches from one sender app to another (e.g. from Movie to Musicify), the new session automatically calls `notifyTakeover()` inside `connect()`. The old `ArozCast` instance in the previous app receives the `takeover` event and cleans up.
+
+If you manage the session outside of `connect()` (e.g. you reuse an open WebSocket), call `notifyTakeover()` manually:
+
+```javascript
+cast.notifyTakeover(); // signals all other open sender tabs
+```
+
+---
+
+## Lifecycle Diagram
+
+```
+new ArozCast()
+      │
+      ▼
+ ┌──────────┐    connect(code)    ┌─────────────┐
+ │  Idle    │ ─────────────────▶ │ Connecting  │
+ └──────────┘                    └──────┬──────┘
+                                        │ onopen
+                                        ▼
+                                  ┌──────────┐   drop    ┌──────────────┐
+                                  │Connected │ ────────▶ │Reconnecting  │
+                                  └──────────┘           └──────┬───────┘
+                                        │                       │ success
+                                        │ disconnect()          ▼
+                                        │              ┌─────Connected────┐
+                                        │              │ (same as above)  │
+                                        ▼              └──────────────────┘
+                                  ┌──────────┐
+                                  │  Idle    │  (media.stop sent to receiver)
+                                  └──────────┘
+
+At any point: destroy() → removes all listeners, closes WS silently
+```
+
+---
+
+## Comparison: SDK vs Raw API
+
+| Task | Raw API | SDK |
+|------|---------|-----|
+| Connect to a room | Manually create WS, attach handlers, send `peer.hello` | `cast.connect('1234')` |
+| Send heartbeat | `setInterval(() => ws.send(...), 5000)` | Automatic |
+| Watchdog (detect dead receiver) | `setInterval(() => { if (stale) ws.close(); }, 4000)` | Automatic |
+| Reconnect on drop | Manual backoff + `visibilitychange` handler | Automatic |
+| Cross-app takeover | `new BroadcastChannel('arozcast').postMessage(...)` | `cast.connect()` or `cast.notifyTakeover()` |
+| Null old WS handlers before reconnect | Manual `ws.onopen = ws.onclose = null; ws.close()` | Automatic |
+| Stop cleanly | `ws.send(media.stop); ws.close()` | `cast.disconnect()` |
+| Page unload cleanup | Manual `beforeunload` listener | Automatic |
+| Receive status updates | `ws.onmessage` + JSON parse + topic switch | `cast.on('status', handler)` |
+| Detect track end | `ws.onmessage` + check `media.ended` topic | `cast.on('ended', handler)` |

+ 197 - 0
src/web/Arozcast/examples/audio-player.html

@@ -0,0 +1,197 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <title>Audio Player Controls — Arozcast Example</title>
+    <script src="../arozcast.js"></script>
+    <style>
+        body  { background:#111; color:#ddd; font-family:sans-serif; padding:24px; max-width:480px; }
+        h2    { margin:0 0 6px; }
+        p     { margin:0 0 14px; color:#888; }
+        code  { color:#aaa; background:#222; padding:1px 5px; border-radius:3px; }
+        input, button { background:#222; color:#ddd; border:1px solid #444; border-radius:4px; padding:5px 10px; }
+        button { cursor:pointer; }
+        button:hover { background:#333; }
+        hr    { border:none; border-top:1px solid #333; margin:16px 0; }
+        #bar  { background:#333; height:8px; border-radius:4px; cursor:pointer; margin:10px 0 4px; }
+        #fill { background:#aaa; height:100%; border-radius:4px; width:0; pointer-events:none; }
+        #time { color:#888; font-size:13px; }
+        #controls { display:none; }
+        #controls button { margin:0 4px 8px 0; }
+        #ended-msg { color:#888; font-size:13px; display:none; }
+    </style>
+</head>
+<body>
+
+<h2>Audio Player Controls</h2>
+<p>
+    Demonstrates <code>seek()</code>, mute toggle, <code>isConnected()</code>,
+    repeat modes, and the <code>ended</code> event.<br>
+    Place your audio at <code>user:/example.mp3</code>.
+</p>
+
+<p>
+    Room code:
+    <input id="code" maxlength="4" size="5" placeholder="1234">
+    <button onclick="doConnect()">Connect</button>
+</p>
+<p id="status">Not connected</p>
+
+<div id="controls">
+    <!-- Transport -->
+    <button onclick="cast.play()">Play</button>
+    <button onclick="cast.pause()">Pause</button>
+    <button onclick="doDisconnect()">Disconnect</button>
+
+    <hr>
+
+    <!-- Progress bar — click to seek(time) to that position -->
+    <div id="bar" onclick="onSeekBarClick(event)">
+        <div id="fill"></div>
+    </div>
+    <span id="time">0:00 / 0:00</span>
+
+    <hr>
+
+    <!-- Volume slider + mute toggle -->
+    Volume: <input type="range" id="vol" min="0" max="100" value="80"
+                   oninput="onVolumeChange()">
+    <button id="mute-btn" onclick="toggleMute()">Mute</button>
+
+    <hr>
+
+    <!-- Repeat — cycles none → all → one, synced via setRepeat() -->
+    <button id="repeat-btn" onclick="cycleRepeat()">Repeat: off</button>
+
+    <p id="ended-msg">⏹ Track finished — this is where you would load the next track.</p>
+
+    <hr>
+
+    <!-- cast.code and cast.connected readable properties -->
+    Room: <span id="room-code">—</span> &nbsp;|&nbsp;
+    Connected: <span id="connected-state">false</span>
+</div>
+
+<script>
+var _s = document.querySelector('script[src*="arozcast.js"]').src;
+var ao_root = _s.slice(0, _s.indexOf('Arozcast/arozcast.js'));
+
+var FILE       = 'user:/example.mp3';
+var muted      = false;
+var repeatMode = 'none';   // 'none' | 'all' | 'one'
+var duration   = 0;
+var currTime   = 0;
+var cast       = new ArozCast({ aoRoot: ao_root });
+
+// ── Events ─────────────────────────────────────────────────────────────────
+
+cast.on('connect', function(e) {
+    status('Connected to room ' + e.code);
+    document.getElementById('controls').style.display = 'block';
+    document.getElementById('ended-msg').style.display = 'none';
+    refreshProps();
+    cast.load({
+        name: 'example.mp3', type: 'audio',
+        src:  ao_root + 'media?file=' + encodeURIComponent(FILE),
+        filepath: FILE,
+    });
+    cast.setVolume(+document.getElementById('vol').value, muted);
+    cast.setRepeat(repeatMode);   // re-sync repeat on every connect / reconnect
+    cast.play();
+});
+
+cast.on('disconnect',   function()  { status('Connection lost — reconnecting…'); refreshProps(); });
+cast.on('reconnecting', function(e) { status('Reconnecting, attempt ' + e.attempt + '…'); });
+cast.on('giveup',       function()  { status('Could not reconnect'); refreshProps(); });
+
+// status event → drive the seek bar and time display
+cast.on('status', function(s) {
+    duration = s.duration;
+    currTime = s.currentTime;
+    updateBar();
+    refreshProps();
+});
+
+// ended event — fired when the track finishes naturally.
+// Not fired when repeat === 'one' (receiver loops natively, no ended event).
+// For repeat === 'all', this is where you would call cast.load() with the next track.
+cast.on('ended', function() {
+    document.getElementById('ended-msg').style.display = '';
+});
+
+// ── Seek bar ────────────────────────────────────────────────────────────────
+
+function onSeekBarClick(e) {
+    // isConnected() is checked explicitly before sending any command
+    if (!cast.isConnected()) return;
+    var t = (e.offsetX / e.currentTarget.offsetWidth) * (duration || 0);
+    cast.seek(t);      // seek(time) — absolute seek in seconds
+    currTime = t;      // optimistic update
+    updateBar();
+}
+
+function updateBar() {
+    var pct = duration ? Math.min(currTime / duration * 100, 100) : 0;
+    document.getElementById('fill').style.width = pct + '%';
+    document.getElementById('time').textContent = fmt(currTime) + ' / ' + fmt(duration);
+}
+
+// ── Volume / mute ───────────────────────────────────────────────────────────
+
+function onVolumeChange() {
+    if (!cast.isConnected()) return;
+    cast.setVolume(+document.getElementById('vol').value, muted);
+}
+
+function toggleMute() {
+    muted = !muted;
+    document.getElementById('mute-btn').textContent = muted ? 'Unmute' : 'Mute';
+    // setVolume(level, true) mutes; setVolume(level, false) unmutes
+    cast.setVolume(+document.getElementById('vol').value, muted);
+}
+
+// ── Repeat ──────────────────────────────────────────────────────────────────
+
+function cycleRepeat() {
+    var modes = ['none', 'all', 'one'];
+    repeatMode = modes[(modes.indexOf(repeatMode) + 1) % modes.length];
+    document.getElementById('repeat-btn').textContent = 'Repeat: ' + repeatMode;
+    cast.setRepeat(repeatMode);
+}
+
+// ── Connect / disconnect ───────────────────────────────────────────────────
+
+function doConnect() {
+    var code = document.getElementById('code').value.trim();
+    if (!/^\d{4}$/.test(code)) { alert('Enter a 4-digit code'); return; }
+    status('Checking room…');
+    cast.ping(code).then(function(ok) {
+        if (!ok) { status('Room not found — is Arozcast open?'); return; }
+        return cast.connect(code);
+    }).catch(function(err) { status('Error: ' + err.message); });
+}
+
+function doDisconnect() {
+    cast.disconnect();
+    status('Disconnected');
+    document.getElementById('controls').style.display = 'none';
+    duration = currTime = 0;
+    refreshProps();
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function refreshProps() {
+    document.getElementById('room-code').textContent       = cast.code      || '—';
+    document.getElementById('connected-state').textContent = cast.connected ? 'true' : 'false';
+}
+
+function status(msg) { document.getElementById('status').textContent = msg; }
+
+function fmt(s) {
+    s = Math.floor(s || 0);
+    return Math.floor(s / 60) + ':' + ('0' + s % 60).slice(-2);
+}
+</script>
+</body>
+</html>

+ 128 - 0
src/web/Arozcast/examples/audio.html

@@ -0,0 +1,128 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <title>Audio Cast — Arozcast Example</title>
+    <script src="../arozcast.js"></script>
+    <style>
+        body { background:#111; color:#ddd; font-family:sans-serif; padding:24px; max-width:460px; }
+        h2   { margin:0 0 6px; }
+        p    { margin:0 0 16px; color:#888; }
+        code { color:#aaa; background:#222; padding:1px 5px; border-radius:3px; }
+        input, button { background:#222; color:#ddd; border:1px solid #444; border-radius:4px; padding:5px 10px; }
+        button { cursor:pointer; }
+        button:hover { background:#333; }
+        #controls { display:none; margin-top:16px; }
+        #controls button { margin:0 4px 8px 0; }
+        #time  { color:#888; font-size:13px; }
+        #ended { color:#888; font-size:13px; margin-top:8px; display:none; }
+    </style>
+</head>
+<body>
+
+<h2>Audio Cast</h2>
+<p>Place your audio at <code>user:/example.mp3</code> before connecting.</p>
+
+<p>
+    Room code:
+    <input id="code" maxlength="4" size="5" placeholder="1234">
+    <button onclick="doConnect()">Connect</button>
+</p>
+
+<p id="status">Not connected</p>
+
+<div id="controls">
+    <button onclick="cast.play()">Play</button>
+    <button onclick="cast.pause()">Pause</button>
+    <button onclick="cycleRepeat()" id="repeat-btn">Repeat: off</button>
+    <button onclick="doDisconnect()">Disconnect</button>
+    <br>
+    Volume: <input type="range" min="0" max="100" value="80"
+                   oninput="cast.setVolume(+this.value, false)">
+    <br>
+    <span id="time">0:00 / 0:00</span>
+    <p id="ended">Track finished.</p>
+</div>
+
+<script>
+// Derive ao_root from the arozcast.js script URL
+// (arozcast.js lives at <ao_root>Arozcast/arozcast.js)
+var _s = document.querySelector('script[src*="arozcast.js"]').src;
+var ao_root = _s.slice(0, _s.indexOf('Arozcast/arozcast.js'));
+
+var FILE       = 'user:/example.mp3';
+var repeatMode = 'none';   // cycles: none → all → one
+var cast       = new ArozCast({ aoRoot: ao_root });
+
+// ── Events ─────────────────────────────────────────────────────────────────
+
+cast.on('connect', function(e) {
+    status('Connected to room ' + e.code);
+    document.getElementById('controls').style.display = 'block';
+    document.getElementById('ended').style.display    = 'none';
+    // Always: load → setVolume → setRepeat → play
+    cast.load({
+        name:     'example.mp3',
+        type:     'audio',
+        src:      ao_root + 'media?file=' + encodeURIComponent(FILE),
+        filepath: FILE,
+    });
+    cast.setVolume(80, false);
+    cast.setRepeat(repeatMode);   // re-sync repeat on every connect / reconnect
+    cast.play();
+});
+
+cast.on('disconnect',   function()  { status('Connection lost — reconnecting…'); });
+cast.on('reconnecting', function(e) { status('Reconnecting, attempt ' + e.attempt + '…'); });
+cast.on('giveup',       function()  { status('Could not reconnect'); document.getElementById('controls').style.display = 'none'; });
+
+// Receiver sends its state every ~3 s
+cast.on('status', function(s) {
+    document.getElementById('time').textContent = fmt(s.currentTime) + ' / ' + fmt(s.duration);
+});
+
+// Fired when the track ends (not fired when repeat === 'one', since the
+// receiver loops natively and never raises the ended event in that mode)
+cast.on('ended', function() {
+    document.getElementById('ended').style.display = '';
+    // With repeat === 'all' you would call cast.load() with the next track here
+});
+
+// ── Repeat cycling ─────────────────────────────────────────────────────────
+
+function cycleRepeat() {
+    var modes = ['none', 'all', 'one'];
+    repeatMode = modes[(modes.indexOf(repeatMode) + 1) % modes.length];
+    document.getElementById('repeat-btn').textContent = 'Repeat: ' + repeatMode;
+    cast.setRepeat(repeatMode);
+}
+
+// ── Connect / disconnect ───────────────────────────────────────────────────
+
+function doConnect() {
+    var code = document.getElementById('code').value.trim();
+    if (!/^\d{4}$/.test(code)) { alert('Enter a 4-digit code'); return; }
+    status('Checking room…');
+    cast.ping(code).then(function(ok) {
+        if (!ok) { status('Room not found — is Arozcast open?'); return; }
+        return cast.connect(code);
+    }).catch(function(err) { status('Error: ' + err.message); });
+}
+
+function doDisconnect() {
+    cast.disconnect();   // sends media.stop to clear the receiver screen
+    status('Disconnected');
+    document.getElementById('controls').style.display = 'none';
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function status(msg) { document.getElementById('status').textContent = msg; }
+
+function fmt(s) {
+    s = Math.floor(s || 0);
+    return Math.floor(s / 60) + ':' + ('0' + s % 60).slice(-2);
+}
+</script>
+</body>
+</html>

+ 94 - 0
src/web/Arozcast/examples/photo.html

@@ -0,0 +1,94 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <title>Photo Cast — Arozcast Example</title>
+    <script src="../arozcast.js"></script>
+    <style>
+        body { background:#111; color:#ddd; font-family:sans-serif; padding:24px; max-width:460px; }
+        h2   { margin:0 0 6px; }
+        p    { margin:0 0 16px; color:#888; }
+        code { color:#aaa; background:#222; padding:1px 5px; border-radius:3px; }
+        input, button { background:#222; color:#ddd; border:1px solid #444; border-radius:4px; padding:5px 10px; }
+        button { cursor:pointer; }
+        button:hover { background:#333; }
+        #controls { display:none; margin-top:16px; }
+        #controls button { margin:0 4px 8px 0; }
+    </style>
+</head>
+<body>
+
+<h2>Photo Cast</h2>
+<p>Place your photo at <code>user:/example.jpg</code> before connecting.</p>
+
+<p>
+    Room code:
+    <input id="code" maxlength="4" size="5" placeholder="1234">
+    <button onclick="doConnect()">Connect</button>
+</p>
+
+<p id="status">Not connected</p>
+
+<div id="controls">
+    <button onclick="sendPhoto()">Send photo to screen</button>
+    <button onclick="doDisconnect()">Disconnect</button>
+</div>
+
+<script>
+// Derive ao_root from the arozcast.js script URL
+// (arozcast.js lives at <ao_root>Arozcast/arozcast.js)
+var _s = document.querySelector('script[src*="arozcast.js"]').src;
+var ao_root = _s.slice(0, _s.indexOf('Arozcast/arozcast.js'));
+
+var FILE = 'user:/example.jpg';
+var cast = new ArozCast({ aoRoot: ao_root });
+
+// ── Events ─────────────────────────────────────────────────────────────────
+
+cast.on('connect', function(e) {
+    status('Connected to room ' + e.code);
+    document.getElementById('controls').style.display = 'block';
+    // Send the photo immediately on connect / reconnect
+    sendPhoto();
+});
+
+cast.on('disconnect',   function()  { status('Connection lost — reconnecting…'); });
+cast.on('reconnecting', function(e) { status('Reconnecting, attempt ' + e.attempt + '…'); });
+cast.on('giveup',       function()  { status('Could not reconnect'); document.getElementById('controls').style.display = 'none'; });
+
+// ── Send photo ─────────────────────────────────────────────────────────────
+
+function sendPhoto() {
+    // For photos, only load() is needed — no play/pause/volume
+    cast.load({
+        name:     'example.jpg',
+        type:     'photo',
+        src:      ao_root + 'media?file=' + encodeURIComponent(FILE),
+        filepath: FILE,
+    });
+}
+
+// ── Connect / disconnect ───────────────────────────────────────────────────
+
+function doConnect() {
+    var code = document.getElementById('code').value.trim();
+    if (!/^\d{4}$/.test(code)) { alert('Enter a 4-digit code'); return; }
+    status('Checking room…');
+    cast.ping(code).then(function(ok) {
+        if (!ok) { status('Room not found — is Arozcast open?'); return; }
+        return cast.connect(code);
+    }).catch(function(err) { status('Error: ' + err.message); });
+}
+
+function doDisconnect() {
+    cast.disconnect();   // sends media.stop to clear the receiver screen
+    status('Disconnected');
+    document.getElementById('controls').style.display = 'none';
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function status(msg) { document.getElementById('status').textContent = msg; }
+</script>
+</body>
+</html>

+ 253 - 0
src/web/Arozcast/examples/utilities.html

@@ -0,0 +1,253 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <title>SDK Utilities — Arozcast Example</title>
+    <script src="../arozcast.js"></script>
+    <style>
+        body  { background:#111; color:#ddd; font-family:sans-serif; padding:24px; max-width:520px; }
+        h2    { margin:0 0 6px; }
+        p     { margin:0 0 14px; color:#888; }
+        code  { color:#aaa; background:#222; padding:1px 5px; border-radius:3px; }
+        input, button, select {
+            background:#222; color:#ddd; border:1px solid #444;
+            border-radius:4px; padding:5px 10px;
+        }
+        input[type=text] { width:160px; }
+        button { cursor:pointer; }
+        button:hover { background:#333; }
+        hr    { border:none; border-top:1px solid #333; margin:16px 0; }
+        h3    { margin:0 0 10px; font-size:14px; color:#bbb; }
+        #log  {
+            background:#0a0a0a; border:1px solid #333; border-radius:4px;
+            padding:10px; height:160px; overflow-y:auto;
+            font-size:12px; font-family:monospace; color:#8a8;
+        }
+        section { margin-bottom:16px; }
+        button  { margin:0 6px 6px 0; }
+    </style>
+</head>
+<body>
+
+<h2>SDK Utilities</h2>
+<p>
+    Demonstrates <code>notifyTakeover()</code>, <code>destroy()</code>,
+    <code>send()</code>, <code>on()</code> / <code>off()</code>,
+    the <code>takeover</code> event, and the <code>.code</code> /
+    <code>.connected</code> properties.
+</p>
+
+<!-- ── Connect ──────────────────────────────────────────────────────────── -->
+<section>
+    <h3>Connect</h3>
+    <p>
+        Room code:
+        <input id="code" maxlength="4" size="5" placeholder="1234">
+        <button onclick="doConnect()">Connect</button>
+        <button onclick="doDisconnect()">Disconnect</button>
+    </p>
+    <p id="status">Not connected</p>
+</section>
+
+<hr>
+
+<!-- ── cast.code / cast.connected properties ────────────────────────────── -->
+<section>
+    <h3><code>cast.code</code> &amp; <code>cast.connected</code> properties</h3>
+    <p>
+        Room: <code id="prop-code">null</code> &nbsp;|&nbsp;
+        Connected: <code id="prop-connected">false</code>
+        &nbsp; <button onclick="refreshProps()">Refresh</button>
+    </p>
+</section>
+
+<hr>
+
+<!-- ── send(topic, payload) ──────────────────────────────────────────────── -->
+<section>
+    <h3><code>send(topic, payload)</code> — raw message</h3>
+    <p>
+        Topic:
+        <select id="raw-topic">
+            <option>media.play</option>
+            <option>media.pause</option>
+            <option>media.stop</option>
+            <option>peer.heartbeat</option>
+        </select>
+        <button onclick="doRawSend()">Send</button>
+    </p>
+    <p style="margin-top:4px">
+        Custom topic: <input id="custom-topic" placeholder="my.topic">
+        Payload JSON: <input id="custom-payload" placeholder='{"key":"val"}'>
+        <button onclick="doCustomSend()">Send custom</button>
+    </p>
+</section>
+
+<hr>
+
+<!-- ── on() / off() ──────────────────────────────────────────────────────── -->
+<section>
+    <h3><code>on()</code> / <code>off()</code> — add and remove listeners</h3>
+    <p>
+        <button onclick="addStatusLog()">Add status logger</button>
+        <button onclick="removeStatusLog()">Remove status logger</button>
+    </p>
+    <p style="color:#888;font-size:13px" id="listener-state">Status logger: not added</p>
+</section>
+
+<hr>
+
+<!-- ── notifyTakeover() ──────────────────────────────────────────────────── -->
+<section>
+    <h3><code>notifyTakeover()</code></h3>
+    <p>
+        Broadcasts an <code>arozcast.takeover</code> BroadcastChannel message.
+        Other open sender tabs (Musicify, Movie, Photo, or other instances of
+        this SDK) will receive the <code>takeover</code> event and yield.
+    </p>
+    <button onclick="doTakeover()">Notify takeover</button>
+</section>
+
+<hr>
+
+<!-- ── destroy() ─────────────────────────────────────────────────────────── -->
+<section>
+    <h3><code>destroy()</code></h3>
+    <p>
+        Releases all SDK resources: removes event listeners, closes the
+        BroadcastChannel, closes the WebSocket silently (no
+        <code>media.stop</code>). Called automatically on page unload.
+        Use it to tear down the instance while the page is still open.
+    </p>
+    <button onclick="doDestroy()">Destroy instance</button>
+</section>
+
+<hr>
+
+<!-- ── Event log ──────────────────────────────────────────────────────────── -->
+<section>
+    <h3>Event log</h3>
+    <div id="log"></div>
+    <button onclick="document.getElementById('log').innerHTML=''" style="margin-top:8px">Clear</button>
+</section>
+
+<script>
+var _s = document.querySelector('script[src*="arozcast.js"]').src;
+var ao_root = _s.slice(0, _s.indexOf('Arozcast/arozcast.js'));
+
+var cast = new ArozCast({ aoRoot: ao_root });
+
+// ── Core event listeners ───────────────────────────────────────────────────
+
+cast.on('connect',      function(e) { log('connect — room ' + e.code); status('Connected to room ' + e.code); refreshProps(); });
+cast.on('disconnect',   function(e) { log('disconnect — room ' + e.code); status('Lost connection — reconnecting…'); refreshProps(); });
+cast.on('reconnecting', function(e) { log('reconnecting — attempt ' + e.attempt + ', delay ' + e.delay + ' ms'); });
+cast.on('giveup',       function(e) { log('giveup — room ' + e.code); status('Could not reconnect'); refreshProps(); });
+
+// takeover event — fires when another sender calls notifyTakeover()
+// (or when connect() is called anywhere, since connect() calls notifyTakeover() automatically)
+cast.on('takeover', function() {
+    log('takeover — another sender claimed the session; this instance yielded');
+    status('Session taken over by another sender');
+    refreshProps();
+});
+
+// ── on() / off() demo ─────────────────────────────────────────────────────
+
+// Named function so it can be removed with off()
+function statusLogger(s) {
+    log('status — time ' + fmt(s.currentTime) + '/' + fmt(s.duration)
+        + ', playing=' + s.isPlaying + ', vol=' + s.volume);
+}
+var statusLoggerAdded = false;
+
+function addStatusLog() {
+    if (statusLoggerAdded) return;
+    cast.on('status', statusLogger);   // on() adds a listener
+    statusLoggerAdded = true;
+    document.getElementById('listener-state').textContent = 'Status logger: added';
+}
+
+function removeStatusLog() {
+    if (!statusLoggerAdded) return;
+    cast.off('status', statusLogger);  // off() removes a specific listener
+    statusLoggerAdded = false;
+    document.getElementById('listener-state').textContent = 'Status logger: removed';
+}
+
+// ── send(topic, payload) ──────────────────────────────────────────────────
+
+function doRawSend() {
+    var topic = document.getElementById('raw-topic').value;
+    cast.send(topic, {});   // send() — raw message, silently dropped if not connected
+    log('send() — topic: ' + topic);
+}
+
+function doCustomSend() {
+    var topic   = document.getElementById('custom-topic').value.trim();
+    var rawJson = document.getElementById('custom-payload').value.trim() || '{}';
+    if (!topic) { alert('Enter a topic'); return; }
+    var payload;
+    try { payload = JSON.parse(rawJson); } catch(e) { alert('Invalid JSON payload'); return; }
+    cast.send(topic, payload);
+    log('send() — topic: ' + topic + ', payload: ' + rawJson);
+}
+
+// ── notifyTakeover() ──────────────────────────────────────────────────────
+
+function doTakeover() {
+    cast.notifyTakeover();  // broadcasts arozcast.takeover on BroadcastChannel
+    log('notifyTakeover() called — other open sender tabs will receive the takeover event');
+}
+
+// ── destroy() ────────────────────────────────────────────────────────────
+
+function doDestroy() {
+    cast.destroy();   // removes all listeners, closes BC and WS silently
+    log('destroy() called — instance is now inert; create a new ArozCast() to reconnect');
+    status('Instance destroyed');
+    refreshProps();
+}
+
+// ── Connect / disconnect ───────────────────────────────────────────────────
+
+function doConnect() {
+    var code = document.getElementById('code').value.trim();
+    if (!/^\d{4}$/.test(code)) { alert('Enter a 4-digit code'); return; }
+    status('Checking room…');
+    cast.ping(code).then(function(ok) {
+        if (!ok) { status('Room not found — is Arozcast open?'); return; }
+        return cast.connect(code);
+    }).catch(function(err) { status('Error: ' + err.message); });
+}
+
+function doDisconnect() {
+    cast.disconnect();
+    status('Disconnected');
+    refreshProps();
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+// Read .code and .connected off the instance directly — they are plain properties
+function refreshProps() {
+    document.getElementById('prop-code').textContent      = JSON.stringify(cast.code);
+    document.getElementById('prop-connected').textContent = String(cast.connected);
+}
+
+function status(msg) { document.getElementById('status').textContent = msg; }
+
+function log(msg) {
+    var el  = document.getElementById('log');
+    var now = new Date().toLocaleTimeString();
+    el.innerHTML += '[' + now + '] ' + msg + '\n';
+    el.scrollTop  = el.scrollHeight;
+}
+
+function fmt(s) {
+    s = Math.floor(s || 0);
+    return Math.floor(s / 60) + ':' + ('0' + s % 60).slice(-2);
+}
+</script>
+</body>
+</html>

+ 189 - 0
src/web/Arozcast/examples/video-player.html

@@ -0,0 +1,189 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <title>Video Player Controls — Arozcast Example</title>
+    <script src="../arozcast.js"></script>
+    <style>
+        body  { background:#111; color:#ddd; font-family:sans-serif; padding:24px; max-width:480px; }
+        h2    { margin:0 0 6px; }
+        p     { margin:0 0 14px; color:#888; }
+        code  { color:#aaa; background:#222; padding:1px 5px; border-radius:3px; }
+        input, button { background:#222; color:#ddd; border:1px solid #444; border-radius:4px; padding:5px 10px; }
+        button { cursor:pointer; }
+        button:hover { background:#333; }
+        hr    { border:none; border-top:1px solid #333; margin:16px 0; }
+        #bar  { background:#333; height:8px; border-radius:4px; cursor:pointer; margin:10px 0 4px; }
+        #fill { background:#aaa; height:100%; border-radius:4px; width:0; pointer-events:none; }
+        #time { color:#888; font-size:13px; }
+        #controls { display:none; }
+        #controls button { margin:0 4px 8px 0; }
+    </style>
+</head>
+<body>
+
+<h2>Video Player Controls</h2>
+<p>
+    Demonstrates <code>seek()</code>, <code>stop()</code>, <code>isConnected()</code>,
+    mute toggle, and the progress bar.<br>
+    Place your video at <code>user:/example.mp4</code>.
+</p>
+
+<p>
+    Room code:
+    <input id="code" maxlength="4" size="5" placeholder="1234">
+    <button onclick="doConnect()">Connect</button>
+</p>
+<p id="status">Not connected</p>
+
+<div id="controls">
+    <!-- Transport -->
+    <button onclick="cast.play()">Play</button>
+    <button onclick="cast.pause()">Pause</button>
+    <button onclick="cast.seekRel(-10)">−10 s</button>
+    <button onclick="cast.seekRel(+10)">+10 s</button>
+
+    <!-- stop() keeps the connection open but clears the receiver screen -->
+    <button onclick="doStop()">Stop</button>
+
+    <button onclick="doDisconnect()">Disconnect</button>
+
+    <hr>
+
+    <!-- Progress bar — click anywhere to seek(time) to that position -->
+    <div id="bar" onclick="onSeekBarClick(event)">
+        <div id="fill"></div>
+    </div>
+    <span id="time">0:00 / 0:00</span>
+
+    <hr>
+
+    <!-- Volume slider + mute toggle -->
+    Volume: <input type="range" id="vol" min="0" max="100" value="80"
+                   oninput="onVolumeChange()">
+    <button id="mute-btn" onclick="toggleMute()">Mute</button>
+
+    <hr>
+
+    <!-- cast.code and cast.connected are readable properties -->
+    Room: <span id="room-code">—</span> &nbsp;|&nbsp;
+    Connected: <span id="connected-state">false</span>
+</div>
+
+<script>
+var _s = document.querySelector('script[src*="arozcast.js"]').src;
+var ao_root = _s.slice(0, _s.indexOf('Arozcast/arozcast.js'));
+
+var FILE     = 'user:/example.mp4';
+var muted    = false;   // local mute state
+var duration = 0;
+var currTime = 0;
+var cast     = new ArozCast({ aoRoot: ao_root });
+
+// ── Events ─────────────────────────────────────────────────────────────────
+
+cast.on('connect', function(e) {
+    status('Connected to room ' + e.code);
+    document.getElementById('controls').style.display = 'block';
+    refreshProps();   // update cast.code / cast.connected display
+    cast.load({
+        name: 'example.mp4', type: 'video',
+        src:  ao_root + 'media?file=' + encodeURIComponent(FILE),
+        filepath: FILE,
+    });
+    cast.setVolume(+document.getElementById('vol').value, muted);
+    cast.play();
+});
+
+cast.on('disconnect',   function()  { status('Connection lost — reconnecting…'); refreshProps(); });
+cast.on('reconnecting', function(e) { status('Reconnecting, attempt ' + e.attempt + '…'); });
+cast.on('giveup',       function()  { status('Could not reconnect'); refreshProps(); });
+
+// status event → drive the progress bar with seek(time)
+cast.on('status', function(s) {
+    duration = s.duration;
+    currTime = s.currentTime;
+    updateBar();
+    refreshProps();
+});
+
+// ── Seek bar ────────────────────────────────────────────────────────────────
+
+function onSeekBarClick(e) {
+    // isConnected() guards against clicks when the socket is temporarily down
+    if (!cast.isConnected()) return;
+    var t = (e.offsetX / e.currentTarget.offsetWidth) * (duration || 0);
+    cast.seek(t);          // seek(time) — absolute position in seconds
+    currTime = t;          // optimistic update; status event corrects within ~3 s
+    updateBar();
+}
+
+function updateBar() {
+    var pct = duration ? Math.min(currTime / duration * 100, 100) : 0;
+    document.getElementById('fill').style.width = pct + '%';
+    document.getElementById('time').textContent = fmt(currTime) + ' / ' + fmt(duration);
+}
+
+// ── Volume / mute ───────────────────────────────────────────────────────────
+
+function onVolumeChange() {
+    // Always use isConnected() before sending, in case slider moves during a brief drop
+    if (!cast.isConnected()) return;
+    cast.setVolume(+document.getElementById('vol').value, muted);
+}
+
+function toggleMute() {
+    muted = !muted;
+    document.getElementById('mute-btn').textContent = muted ? 'Unmute' : 'Mute';
+    // setVolume(level, muted) — pass true to mute without changing the level
+    cast.setVolume(+document.getElementById('vol').value, muted);
+}
+
+// ── stop() vs disconnect() ──────────────────────────────────────────────────
+
+function doStop() {
+    // stop() sends media.stop to the receiver — clears its screen —
+    // but keeps the WebSocket open so you can load() a new file later.
+    cast.stop();
+    status('Stopped (still connected — load a new file or disconnect)');
+    duration = currTime = 0;
+    updateBar();
+}
+
+// ── Connect / disconnect ───────────────────────────────────────────────────
+
+function doConnect() {
+    var code = document.getElementById('code').value.trim();
+    if (!/^\d{4}$/.test(code)) { alert('Enter a 4-digit code'); return; }
+    status('Checking room…');
+    cast.ping(code).then(function(ok) {
+        if (!ok) { status('Room not found — is Arozcast open?'); return; }
+        return cast.connect(code);
+    }).catch(function(err) { status('Error: ' + err.message); });
+}
+
+function doDisconnect() {
+    cast.disconnect();
+    status('Disconnected');
+    document.getElementById('controls').style.display = 'none';
+    duration = currTime = 0;
+    refreshProps();
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+// Read cast.code and cast.connected properties and show them live
+function refreshProps() {
+    document.getElementById('room-code').textContent      = cast.code      || '—';
+    document.getElementById('connected-state').textContent = cast.connected ? 'true' : 'false';
+}
+
+function status(msg) { document.getElementById('status').textContent = msg; }
+
+function fmt(s) {
+    s = Math.floor(s || 0);
+    return Math.floor(s / 60) + ':' + ('0' + s % 60).slice(-2);
+}
+</script>
+</body>
+</html>

+ 108 - 0
src/web/Arozcast/examples/video.html

@@ -0,0 +1,108 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <title>Video Cast — Arozcast Example</title>
+    <script src="../arozcast.js"></script>
+    <style>
+        body { background:#111; color:#ddd; font-family:sans-serif; padding:24px; max-width:460px; }
+        h2   { margin:0 0 6px; }
+        p    { margin:0 0 16px; color:#888; }
+        code { color:#aaa; background:#222; padding:1px 5px; border-radius:3px; }
+        input, button { background:#222; color:#ddd; border:1px solid #444; border-radius:4px; padding:5px 10px; }
+        button { cursor:pointer; }
+        button:hover { background:#333; }
+        #controls { display:none; margin-top:16px; }
+        #controls button { margin:0 4px 8px 0; }
+        #time { color:#888; font-size:13px; }
+    </style>
+</head>
+<body>
+
+<h2>Video Cast</h2>
+<p>Place your video at <code>user:/example.mp4</code> before connecting.</p>
+
+<p>
+    Room code:
+    <input id="code" maxlength="4" size="5" placeholder="1234">
+    <button onclick="doConnect()">Connect</button>
+</p>
+
+<p id="status">Not connected</p>
+
+<div id="controls">
+    <button onclick="cast.play()">Play</button>
+    <button onclick="cast.pause()">Pause</button>
+    <button onclick="cast.seekRel(-10)">−10 s</button>
+    <button onclick="cast.seekRel(+10)">+10 s</button>
+    <button onclick="doDisconnect()">Disconnect</button>
+    <br>
+    Volume: <input type="range" min="0" max="100" value="80"
+                   oninput="cast.setVolume(+this.value, false)">
+    <br>
+    <span id="time">0:00 / 0:00</span>
+</div>
+
+<script>
+// Derive ao_root from the arozcast.js script URL
+// (arozcast.js lives at <ao_root>Arozcast/arozcast.js)
+var _s = document.querySelector('script[src*="arozcast.js"]').src;
+var ao_root = _s.slice(0, _s.indexOf('Arozcast/arozcast.js'));
+
+var FILE = 'user:/example.mp4';
+var cast = new ArozCast({ aoRoot: ao_root });
+
+// ── Events ─────────────────────────────────────────────────────────────────
+
+cast.on('connect', function(e) {
+    status('Connected to room ' + e.code);
+    document.getElementById('controls').style.display = '';
+    // Always: load → setVolume → play
+    cast.load({
+        name:     'example.mp4',
+        type:     'video',
+        src:      ao_root + 'media?file=' + encodeURIComponent(FILE),
+        filepath: FILE,
+    });
+    cast.setVolume(80, false);
+    cast.play();
+});
+
+cast.on('disconnect',   function()  { status('Connection lost — reconnecting…'); });
+cast.on('reconnecting', function(e) { status('Reconnecting, attempt ' + e.attempt + '…'); });
+cast.on('giveup',       function()  { status('Could not reconnect'); document.getElementById('controls').style.display = 'none'; });
+
+// Receiver sends its state every ~3 s — use it to show current time
+cast.on('status', function(s) {
+    document.getElementById('time').textContent = fmt(s.currentTime) + ' / ' + fmt(s.duration);
+});
+
+// ── Connect / disconnect ───────────────────────────────────────────────────
+
+function doConnect() {
+    var code = document.getElementById('code').value.trim();
+    if (!/^\d{4}$/.test(code)) { alert('Enter a 4-digit code'); return; }
+    status('Checking room…');
+    cast.ping(code).then(function(ok) {
+        if (!ok) { status('Room not found — is Arozcast open?'); return; }
+        return cast.connect(code);
+    }).catch(function(err) { status('Error: ' + err.message); });
+}
+
+function doDisconnect() {
+    cast.disconnect();   // sends media.stop to clear the receiver screen
+    status('Disconnected');
+    document.getElementById('controls').style.display = 'none';
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function status(msg) { document.getElementById('status').textContent = msg; }
+
+function fmt(s) {
+    s = Math.floor(s || 0);
+    return Math.floor(s / 60) + ':' + ('0' + s % 60).slice(-2);
+}
+</script>
+</body>
+</html>