Просмотр исходного кода

Add Arozcast docs and repeat sync support

Add a full Arozcast Developer API reference (src/web/Arozcast/README.md) documenting HTTP endpoints, WebSocket protocol and integration guidance. Update the Arozcast receiver (src/web/Arozcast/index.html) to show a repeat indicator, handle incoming media.repeat messages, and apply native loop only for 'one' mode (keep 'all' driven by sender via media.ended). Send media.repeat from Musicify (src/web/Musicify/musicify.js) when the repeat mode changes and when (re)connecting so sender and receiver stay synchronized; also add small toolbar CSS for the active state.
Toby Chui 2 недель назад
Родитель
Сommit
9096330532
3 измененных файлов с 756 добавлено и 0 удалено
  1. 725 0
      src/web/Arozcast/README.md
  2. 26 0
      src/web/Arozcast/index.html
  3. 5 0
      src/web/Musicify/musicify.js

+ 725 - 0
src/web/Arozcast/README.md

@@ -0,0 +1,725 @@
+# Arozcast — Developer API Reference
+
+Arozcast is ArozOS's built-in remote-projection relay. It uses a **room-based WebSocket pub/sub** model: a sender (e.g. Musicify, Movie) opens a room and controls playback; a receiver (the Arozcast webapp running on a TV or second screen) joins the same room and acts on the commands it receives.
+
+Any ArozOS webapp can become a sender by using the HTTP and WebSocket APIs documented below. **Login is required** for all endpoints.
+
+---
+
+## Table of Contents
+
+1. [Architecture Overview](#architecture-overview)
+2. [HTTP Endpoints](#http-endpoints)
+   - [POST /api/arozcast/create](#post-apiarozcastcreate)
+   - [GET /api/arozcast/ping](#get-apiarozcastping)
+   - [GET /api/arozcast/close](#get-apiarozcastclose)
+   - [POST /api/arozcast/publish](#post-apiarozcastpublish)
+   - [GET /api/arozcast/ws](#get-apiarozcastws)
+3. [WebSocket Message Protocol](#websocket-message-protocol)
+   - [Message Envelope](#message-envelope)
+   - [Sender → Receiver Topics](#sender--receiver-topics)
+   - [Receiver → Sender Topics](#receiver--sender-topics)
+4. [Complete Integration Walkthrough](#complete-integration-walkthrough)
+5. [Reconnection & Resilience](#reconnection--resilience)
+6. [Best Practices & Notes](#best-practices--notes)
+
+---
+
+## Architecture Overview
+
+```
+Sender webapp                Arozcast relay               Arozcast receiver
+(Musicify, Movie, …)         (Go backend)                 (index.html on TV)
+
+  POST /create  ──────────→  allocates 4-digit room
+  WS  /ws?code  ◄──────────→  joins room #1234   ◄──────────  WS /ws?code
+  media.load    ──────────→  broadcast to all    ──────────→  _loadMedia()
+  media.play    ──────────→  broadcast to all    ──────────→  _play()
+                ◄──────────  status.update every 3s ◄──────  setInterval
+  media.seekrel ──────────→  broadcast to all    ──────────→  _seek(t+Δ)
+  media.stop    ──────────→  broadcast to all    ──────────→  _stop()
+  GET  /close   ──────────→  closes room, kicks all clients
+```
+
+Key design points:
+- The relay is **dumb**: every WebSocket frame sent by a client is echoed to all *other* clients in the room. No processing happens server-side.
+- Rooms are created by the **sender** and destroyed by the sender (or cleaned up after 10 minutes of inactivity).
+- The receiver broadcasts `status.update` every 3 seconds so the sender can stay in sync even after a reconnect.
+
+---
+
+## HTTP Endpoints
+
+All endpoints are under `/api/arozcast/` and require an authenticated ArozOS session cookie.
+
+---
+
+### POST /api/arozcast/create
+
+Creates a new room and returns a 4-digit code.
+
+**Request:** No body required.
+
+**Response:**
+```json
+{ "code": "1234" }
+```
+
+**Example (fetch):**
+```javascript
+const res  = await fetch(ao_root + 'api/arozcast/create', { method: 'POST' });
+const data = await res.json();
+const code = data.code; // e.g. "1234"
+```
+
+**Example (jQuery):**
+```javascript
+$.post(ao_root + 'api/arozcast/create', function(data) {
+    var code = data.code;
+});
+```
+
+---
+
+### GET /api/arozcast/ping
+
+Checks whether a room with the given code currently exists.
+
+**Query parameter:** `code` — the 4-digit room code.
+
+**Response:**
+```json
+{ "exists": true }
+// or
+{ "exists": false }
+```
+
+**Example:**
+```javascript
+fetch(ao_root + 'api/arozcast/ping?code=' + code)
+    .then(r => r.json())
+    .then(d => {
+        if (d.exists) { /* room is alive */ }
+    });
+```
+
+Use this before displaying a "Reconnect" UI to confirm the receiver is still running.
+
+---
+
+### GET /api/arozcast/close
+
+Closes a room and forcibly disconnects all WebSocket clients.
+
+**Query parameter:** `code` — the room code to close.
+
+**Response:** `"OK"`
+
+**Example:**
+```javascript
+// Reliable even during page unload:
+navigator.sendBeacon(ao_root + 'api/arozcast/close?code=' + code);
+
+// Or with fetch during normal teardown:
+await fetch(ao_root + 'api/arozcast/close?code=' + code);
+```
+
+Always call this (preferably via `sendBeacon`) in the sender's `beforeunload` handler so the receiver's room is cleaned up promptly.
+
+---
+
+### POST /api/arozcast/publish
+
+Broadcasts a raw JSON message to every client in the room **without** requiring a WebSocket connection. Intended for AGI scripts or server-side integrations that cannot hold a long-lived connection.
+
+**Form parameters:**
+| Field | Type   | Description                        |
+|-------|--------|------------------------------------|
+| `code`| string | 4-digit room code                  |
+| `msg` | string | JSON-encoded message (see protocol)|
+
+**Response:** `"OK"` or `{"error":"…"}`
+
+**Example (curl):**
+```bash
+curl -X POST https://your-arozos/api/arozcast/publish \
+     -d "code=1234" \
+     --data-urlencode 'msg={"topic":"media.pause","payload":{}}'
+```
+
+**Example (AGI / JavaScript):**
+```javascript
+var payload = JSON.stringify({ topic: 'media.pause', payload: {} });
+$.post(ao_root + 'api/arozcast/publish', { code: code, msg: payload });
+```
+
+---
+
+### GET /api/arozcast/ws
+
+Upgrades the connection to a WebSocket and joins the room. Every text frame sent by this client is relayed to all other participants in the room.
+
+**Query parameter:** `code` — the room code (must already exist).
+
+**Protocol:** `ws://` or `wss://` (matches the page's HTTP/HTTPS scheme).
+
+**Example:**
+```javascript
+var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
+wsUrl.protocol = (location.protocol === 'https:') ? 'wss:' : 'ws:';
+var ws = new WebSocket(wsUrl.toString());
+```
+
+Frames are plain text containing JSON. See [Message Protocol](#websocket-message-protocol) for the format.
+
+---
+
+## WebSocket Message Protocol
+
+### Message Envelope
+
+Every frame — in both directions — uses the same JSON envelope:
+
+```json
+{
+    "topic":   "<topic-string>",
+    "payload": { /* topic-specific fields */ }
+}
+```
+
+`topic` is a dot-separated string that identifies the message type. `payload` is an object (never null; use `{}` for topics with no data).
+
+---
+
+### Sender → Receiver Topics
+
+These are sent by the controlling webapp (Musicify, Movie, Photo, or your own app) and acted on by the Arozcast receiver.
+
+---
+
+#### `peer.hello`
+
+Announces that a sender has connected or reconnected. The receiver uses this to mark the sender as active and reset its watchdog timer.
+
+Send on: initial WebSocket open, and on every reconnect.
+
+```json
+{ "topic": "peer.hello", "payload": {} }
+```
+
+---
+
+#### `peer.heartbeat`
+
+Keeps the sender's presence alive. The receiver considers a sender disconnected if no message is received for 12 seconds.
+
+Send on: a 5-second `setInterval` while the WebSocket is open.
+
+```json
+{ "topic": "peer.heartbeat", "payload": {} }
+```
+
+---
+
+#### `media.load`
+
+Instructs the receiver to load and immediately begin playing a new media item.
+
+```json
+{
+    "topic": "media.load",
+    "payload": {
+        "name":      "My Song.flac",
+        "type":      "audio",
+        "src":       "https://arozos.host/media?file=%2Fmusic%2Fsong.flac",
+        "filepath":  "/music/song.flac",
+        "startTime": 42.5,
+        "artist":    "Artist Name",
+        "cover":     "https://…/cover.jpg"
+    }
+}
+```
+
+| Field       | Type   | Required | Description                                                                           |
+|-------------|--------|----------|---------------------------------------------------------------------------------------|
+| `name`      | string | ✓        | Display name shown in the toolbar                                                     |
+| `type`      | string | ✓        | `"audio"`, `"video"`, or `"photo"`                                                    |
+| `src`       | string | ✓        | Full playback URL (use the transcoding API if the format may not be natively playable)|
+| `filepath`  | string | ✓        | ArozOS virtual path; used by the receiver to load album art                           |
+| `startTime` | number |          | Seconds to seek to before playback (default: `0`)                                     |
+| `artist`    | string |          | Artist subtitle shown in audio mode                                                   |
+| `cover`     | string |          | Cover image URL override (audio mode)                                                 |
+
+Always send `media.volume` **after** `media.load` and **before** `media.play` so volume is set before the browser begins decoding.
+
+---
+
+#### `media.play`
+
+Resumes or starts playback.
+
+```json
+{ "topic": "media.play", "payload": {} }
+```
+
+---
+
+#### `media.pause`
+
+Pauses playback.
+
+```json
+{ "topic": "media.pause", "payload": {} }
+```
+
+---
+
+#### `media.seek`
+
+Seeks to an absolute position.
+
+```json
+{
+    "topic": "media.seek",
+    "payload": { "time": 123.4 }
+}
+```
+
+| Field  | Type   | Description                  |
+|--------|--------|------------------------------|
+| `time` | number | Target position in seconds   |
+
+> **Prefer `media.seekrel` for keyboard/button skipping** — see below.
+
+---
+
+#### `media.seekrel`
+
+Seeks by a relative delta. The receiver applies the delta to its **live** `currentTime`, so rapid presses accumulate correctly even before a `status.update` has arrived.
+
+```json
+{
+    "topic": "media.seekrel",
+    "payload": { "delta": 10 }
+}
+```
+
+| Field   | Type   | Description                                       |
+|---------|--------|---------------------------------------------------|
+| `delta` | number | Seconds to skip (positive = forward, negative = backward) |
+
+**Example — keyboard skip with optimistic local UI:**
+```javascript
+case 'ArrowRight':
+    castSend('media.seekrel', { delta: 10 });
+    castCurrentTime = Math.min(castDuration, castCurrentTime + 10);
+    updateProgressUI();
+    break;
+case 'ArrowLeft':
+    castSend('media.seekrel', { delta: -10 });
+    castCurrentTime = Math.max(0, castCurrentTime - 10);
+    updateProgressUI();
+    break;
+```
+
+---
+
+#### `media.volume`
+
+Sets the playback volume and mute state. The receiver applies this to both its audio and video elements.
+
+```json
+{
+    "topic": "media.volume",
+    "payload": {
+        "volume": 80,
+        "muted":  false
+    }
+}
+```
+
+| Field    | Type    | Description                        |
+|----------|---------|------------------------------------|
+| `volume` | number  | Volume level, 0–100                |
+| `muted`  | boolean | Whether audio is muted             |
+
+> **Scale note:** Arozcast uses **0–100** for volume. If your sender's native element uses 0–1 (like a `<video>` element), multiply by 100 before sending.
+
+---
+
+#### `media.repeat`
+
+Syncs the repeat mode. The receiver sets `el.loop = true` for `'one'` (browser handles looping natively) and only shows a visual indicator for `'all'` (the sender drives playlist advancement via `media.ended`).
+
+```json
+{
+    "topic": "media.repeat",
+    "payload": { "mode": "one" }
+}
+```
+
+| `mode`   | Meaning                                    |
+|----------|--------------------------------------------|
+| `"none"` | No repeat                                  |
+| `"one"`  | Loop the current track (receiver sets `loop=true`) |
+| `"all"`  | Loop the playlist (sender listens for `media.ended` and loads the next track) |
+
+Send on: user changes repeat mode, initial cast connection, and every reconnect.
+
+---
+
+#### `media.stop`
+
+Stops playback and clears the current track from the receiver's UI. The receiver returns to its idle/waiting screen.
+
+```json
+{ "topic": "media.stop", "payload": {} }
+```
+
+**Only send this when the user explicitly disconnects.** Do **not** send it on page unload or on a broken WebSocket — this would stop the receiver even when the sender only navigated away or the phone went to sleep. Let the receiver keep playing and display its "sender disconnected" banner instead.
+
+---
+
+### Receiver → Sender Topics
+
+These are sent by the Arozcast receiver back to the sender.
+
+---
+
+#### `status.update`
+
+Broadcast by the receiver every **3 seconds** so all connected senders can stay in sync.
+
+```json
+{
+    "topic": "status.update",
+    "payload": {
+        "currentTime": 87.4,
+        "duration":    240.0,
+        "isPlaying":   true,
+        "volume":      80,
+        "isMuted":     false,
+        "peerCount":   1
+    }
+}
+```
+
+On reconnect, do **not** push the sender's local time to the receiver. Instead, wait for the next `status.update` (arrives within 3 seconds) and let it overwrite your local display. This prevents stale sender-side time from rewinding a track that kept playing while the phone was asleep.
+
+---
+
+#### `media.ended`
+
+Sent by the receiver when the current track finishes naturally (i.e. `loop` is `false`). The sender should respond by loading the next track (for `repeat === 'all'`) or doing nothing (for `repeat === 'none'`).
+
+```json
+{ "topic": "media.ended", "payload": {} }
+```
+
+---
+
+## Complete Integration Walkthrough
+
+Below is a minimal but complete sender implementation in plain JavaScript.
+
+```javascript
+// ── State ────────────────────────────────────────────────────────────────
+var castWs        = null;
+var castCode      = null;
+var castMode      = false;
+var castDuration  = 0;
+var castTime      = 0;
+var castPlaying   = false;
+var castPingTimer = null;
+
+// ── Helpers ──────────────────────────────────────────────────────────────
+function castSend(topic, payload) {
+    if (castWs && castWs.readyState === WebSocket.OPEN) {
+        castWs.send(JSON.stringify({ topic: topic, payload: payload }));
+    }
+}
+
+function castConnected() {
+    return castMode && castWs && castWs.readyState === WebSocket.OPEN;
+}
+
+// ── 1. Create a room and open the WebSocket ───────────────────────────────
+async function startCast() {
+    // Create room
+    const res  = await fetch(ao_root + 'api/arozcast/create', { method: 'POST' });
+    const data = await res.json();
+    castCode = data.code;
+
+    // Show the code to the user so they can enter it in the Arozcast receiver
+    showCodeUI(castCode);
+
+    // Connect WebSocket
+    var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + castCode, window.location.href);
+    wsUrl.protocol = (location.protocol === 'https:') ? 'wss:' : 'ws:';
+    castWs = new WebSocket(wsUrl.toString());
+
+    castWs.onopen = function() {
+        castMode = true;
+
+        // Announce presence
+        castSend('peer.hello', {});
+
+        // Send current media + volume state so receiver syncs immediately
+        var video = document.getElementById('my-video');
+        castSend('media.load', {
+            name:      currentEpisode.name,
+            type:      'video',
+            src:       currentEpisode.url,       // full playback URL
+            filepath:  currentEpisode.filepath,  // ArozOS vpath
+            startTime: video.currentTime
+        });
+        castSend('media.volume', { volume: video.volume * 100, muted: video.muted });
+        castSend(video.paused ? 'media.pause' : 'media.play', {});
+
+        // Pause local playback — receiver takes over
+        video.pause();
+
+        // Heartbeat
+        castPingTimer = setInterval(function() {
+            castSend('peer.heartbeat', {});
+        }, 5000);
+    };
+
+    castWs.onmessage = function(evt) {
+        var msg = JSON.parse(evt.data);
+        if (msg.topic === 'status.update') {
+            // Sync sender-side progress display
+            castTime     = msg.payload.currentTime;
+            castDuration = msg.payload.duration;
+            castPlaying  = msg.payload.isPlaying;
+            updateProgressUI();
+        } else if (msg.topic === 'media.ended') {
+            loadNextTrack(); // advance playlist
+        }
+    };
+
+    castWs.onclose = function() {
+        clearInterval(castPingTimer);
+        castMode = false; castWs = null;
+        updateCastUI();
+    };
+}
+
+// ── 2. Send playback commands ─────────────────────────────────────────────
+function togglePlayPause() {
+    if (castConnected()) {
+        if (castPlaying) {
+            castSend('media.pause', {});
+            castPlaying = false;             // optimistic update
+        } else {
+            castSend('media.play', {});
+            castPlaying = true;
+        }
+        updatePlayIcon();
+        return;
+    }
+    // fallback: control local video
+    var v = document.getElementById('my-video');
+    v.paused ? v.play() : v.pause();
+}
+
+function skipForward(seconds) {
+    if (castConnected()) {
+        castSend('media.seekrel', { delta: seconds });
+        castTime = Math.min(castDuration, castTime + seconds); // optimistic
+        updateProgressUI();
+        return;
+    }
+    var v = document.getElementById('my-video');
+    v.currentTime = Math.min(v.duration || 0, v.currentTime + seconds);
+}
+
+function setVolume(pct, muted) {
+    if (castConnected()) {
+        castSend('media.volume', { volume: pct, muted: muted });
+    }
+    // also update local video for when we disconnect
+    var v = document.getElementById('my-video');
+    v.volume = pct / 100;
+    v.muted  = muted;
+}
+
+// ── 3. Load a new track while casting ────────────────────────────────────
+function castLoadTrack(episode) {
+    if (!castConnected()) return;
+    castSend('media.load', {
+        name:      episode.name,
+        type:      'video',
+        src:       episode.url,
+        filepath:  episode.filepath,
+        startTime: 0
+    });
+    castSend('media.volume', { volume: myVideo().volume * 100, muted: myVideo().muted });
+    castSend('media.play', {});
+}
+
+// ── 4. Explicitly disconnect ──────────────────────────────────────────────
+function disconnectCast() {
+    if (castConnected()) {
+        castSend('media.stop', {}); // tell receiver to clear its screen
+    }
+    if (castWs) { castWs.onclose = null; castWs.close(); castWs = null; }
+    clearInterval(castPingTimer);
+    castMode = false;
+
+    // Resume locally at the last known position
+    var v = document.getElementById('my-video');
+    v.currentTime = castTime;
+    v.play();
+}
+
+// ── 5. Clean up on page unload ────────────────────────────────────────────
+window.addEventListener('beforeunload', function() {
+    // Do NOT send media.stop — receiver should keep playing.
+    // Close the WS silently and ask the server to clean up the room.
+    if (castWs) { castWs.onclose = null; castWs.close(); }
+    if (castCode) { navigator.sendBeacon(ao_root + 'api/arozcast/close?code=' + castCode); }
+});
+```
+
+---
+
+## Reconnection & Resilience
+
+Mobile browsers suspend WebSocket connections when the screen locks. Implement exponential-backoff reconnection so the cast session survives a brief sleep.
+
+```javascript
+var RECONNECT_DELAYS   = [2000, 5000, 12000]; // ms
+var reconnectCount     = 0;
+var reconnectTimer     = null;
+var pendingCode        = null;
+
+function onCastDisconnect(savedCode) {
+    castMode = false; castWs = null;
+    pendingCode = savedCode;
+    scheduleReconnect();
+}
+
+function scheduleReconnect() {
+    if (reconnectCount >= RECONNECT_DELAYS.length) {
+        // Give up — fall back to local playback
+        reconnectCount = 0; pendingCode = null;
+        resumeLocally();
+        return;
+    }
+    var delay = RECONNECT_DELAYS[reconnectCount++];
+    clearTimeout(reconnectTimer);
+    reconnectTimer = setTimeout(attemptReconnect, delay);
+}
+
+function attemptReconnect() {
+    if (!pendingCode) return;
+    var code = pendingCode;
+
+    var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
+    wsUrl.protocol = (location.protocol === 'https:') ? 'wss:' : 'ws:';
+    var ws = new WebSocket(wsUrl.toString());
+
+    var timeout = setTimeout(function() {
+        ws.onopen = ws.onclose = ws.onerror = null; ws.close();
+        scheduleReconnect(); // timed out — try again
+    }, 8000);
+
+    ws.onopen = function() {
+        clearTimeout(timeout);
+        reconnectCount = 0; pendingCode = null;
+        castWs = ws; castCode = code; castMode = true;
+
+        // Re-announce only — do NOT resend media.load.
+        // The receiver kept playing; status.update will sync time within 3 s.
+        castSend('peer.hello', {});
+        castSend('media.volume', { volume: myVideo().volume * 100, muted: myVideo().muted });
+
+        startHeartbeat();
+        showToast('Arozcast reconnected');
+    };
+
+    ws.onclose = function() {
+        clearTimeout(timeout);
+        scheduleReconnect();
+    };
+}
+
+// Wake-up accelerator: retry immediately when the tab/app comes back to foreground
+document.addEventListener('visibilitychange', function() {
+    if (document.visibilityState === 'visible' && pendingCode) {
+        clearTimeout(reconnectTimer);
+        reconnectTimer = null;
+        attemptReconnect();
+    }
+});
+```
+
+### Reconnection rules
+
+| Situation | Action |
+|-----------|--------|
+| WS drops unexpectedly | Retry up to 3 times (2 s / 5 s / 12 s backoff) |
+| Tab becomes visible while retrying | Retry immediately |
+| All retries exhausted | Call `resumeLocally()` — fall back to device playback |
+| User explicitly presses "Disconnect" | Send `media.stop`, skip retries, resume locally |
+| Page/tab closed | Send only `media.stop` if explicit disconnect; otherwise let receiver keep playing |
+
+---
+
+## Best Practices & Notes
+
+### Volume scale
+Arozcast uses **0–100** for volume. HTML `<video>` / `<audio>` elements use **0–1**. Always multiply by 100 before sending and divide by 100 after receiving.
+
+```javascript
+// Sending:
+castSend('media.volume', { volume: videoEl.volume * 100, muted: videoEl.muted });
+
+// Receiving status.update:
+videoEl.volume = payload.volume / 100;
+```
+
+### Ordering of messages on initial load
+Always send in this order:
+1. `media.load` (sets the file)
+2. `media.volume` (sets volume **before** decoding begins)
+3. `media.play` or `media.pause` (starts/withholds playback)
+4. `media.repeat` (sets loop state)
+
+Sending `media.volume` after `media.play` can race the browser's default volume assignment.
+
+### Optimistic UI for seek and play/pause
+Because `status.update` arrives every 3 seconds, applying seek/play/pause commands to your sender-side progress bar immediately (before confirmation) makes the UI feel responsive:
+
+```javascript
+// Optimistic seek
+castSend('media.seekrel', { delta: 10 });
+castTime = Math.min(castDuration, castTime + 10); // update sender UI now
+updateProgressUI();                                // status.update will correct within 3 s
+```
+
+### `media.stop` — send only on explicit disconnect
+Do **not** send `media.stop` when the page unloads or the WebSocket drops. The receiver will display a "sender disconnected" banner but continue playing. This is the desired behaviour for mobile devices that lock the screen.
+
+Only send `media.stop` when the user explicitly clicks "Disconnect cast".
+
+### `repeat === 'all'` requires the sender to advance the playlist
+Setting `repeat === 'all'` does **not** make the receiver loop automatically. Instead:
+- The receiver fires `media.ended` when the current track finishes.
+- The sender receives `media.ended` and calls `media.load` with the next track.
+
+Setting `repeat === 'one'` sets `loop = true` on the receiver's media element, so the browser handles looping natively and `media.ended` is never fired.
+
+### Room lifetime
+Rooms are automatically garbage-collected after **10 minutes of inactivity** (no connected clients). Always call `/api/arozcast/close` when tearing down intentionally so the slot is freed immediately.
+
+### HTTP publish for non-WS contexts
+AGI scripts and server-side code that cannot hold a WebSocket can use `/api/arozcast/publish` to inject any message into a live room. This is useful for automation (e.g. skip to next track on a timer) without modifying the frontend.
+
+```bash
+# Pause playback from the command line
+curl -X POST https://your-arozos/api/arozcast/publish \
+     -d "code=1234" \
+     --data-urlencode 'msg={"topic":"media.pause","payload":{}}'
+```

+ 26 - 0
src/web/Arozcast/index.html

@@ -176,6 +176,8 @@
         }
         }
         .tb-btn:hover { background: rgba(255,255,255,.18); }
         .tb-btn:hover { background: rgba(255,255,255,.18); }
         .tb-btn svg { width: 18px; height: 18px; fill: currentColor; }
         .tb-btn svg { width: 18px; height: 18px; fill: currentColor; }
+        .tb-btn.active { background: rgba(168,85,247,.28); }
+        .tb-btn.active img { filter: brightness(1.4) saturate(1.6); }
 
 
         .tb-filename {
         .tb-filename {
             flex: 1; text-align: center;
             flex: 1; text-align: center;
@@ -361,6 +363,13 @@
         <button class="tb-btn" @click="toggleMute()" :title="isMuted ? 'Unmute' : 'Mute'">
         <button class="tb-btn" @click="toggleMute()" :title="isMuted ? 'Unmute' : 'Mute'">
             <img :src="isMuted ? 'img/mute.svg' : 'img/unmute.svg'" style="width:20px;height:20px;">
             <img :src="isMuted ? 'img/mute.svg' : 'img/unmute.svg'" style="width:20px;height:20px;">
         </button>
         </button>
+
+        <!-- Repeat indicator (controlled by sender) -->
+        <button class="tb-btn" :class="{active: repeatMode !== 'none'}"
+                :title="repeatMode === 'none' ? 'Repeat: off' : repeatMode === 'one' ? 'Repeat: one' : 'Repeat: all'"
+                x-show="currentTrack" style="pointer-events:none;">
+            <img src="img/repeat.svg" style="width:20px;height:20px;">
+        </button>
     </div>
     </div>
 
 
     <!-- ── Progress bar ───────────────────────────────────────────── -->
     <!-- ── Progress bar ───────────────────────────────────────────── -->
@@ -401,6 +410,7 @@ function arozcastApp() {
         duration: 0,
         duration: 0,
         volume: 100,
         volume: 100,
         isMuted: false,
         isMuted: false,
+        repeatMode: 'none',   // 'none' | 'one' | 'all' — set by sender via media.repeat
 
 
         // Toolbar
         // Toolbar
         isFullscreen: false,
         isFullscreen: false,
@@ -530,6 +540,9 @@ function arozcastApp() {
                 case 'media.volume':
                 case 'media.volume':
                     this._setVolume(msg.payload.volume, msg.payload.muted);
                     this._setVolume(msg.payload.volume, msg.payload.muted);
                     break;
                     break;
+                case 'media.repeat':
+                    this._setRepeat(msg.payload.mode || 'none');
+                    break;
                 case 'media.stop':
                 case 'media.stop':
                     this._stop();
                     this._stop();
                     break;
                     break;
@@ -571,6 +584,7 @@ function arozcastApp() {
 
 
             if (type === 'audio') {
             if (type === 'audio') {
                 this.coverSrc = ao_root + 'system/file_system/loadThumbnail?bytes=true&vpath=' + encodeURIComponent(track.filepath);
                 this.coverSrc = ao_root + 'system/file_system/loadThumbnail?bytes=true&vpath=' + encodeURIComponent(track.filepath);
+                this._audio.loop = (this.repeatMode === 'one');
                 this._audio.src = fileUrl;
                 this._audio.src = fileUrl;
                 this._audio.load();
                 this._audio.load();
                 // Apply volume and seek inside loadedmetadata so browser-assigned
                 // Apply volume and seek inside loadedmetadata so browser-assigned
@@ -585,6 +599,7 @@ function arozcastApp() {
                 this._video.pause();
                 this._video.pause();
                 this._video.removeAttribute('src');
                 this._video.removeAttribute('src');
             } else if (type === 'video') {
             } else if (type === 'video') {
+                this._video.loop = (this.repeatMode === 'one');
                 this._video.src = fileUrl;
                 this._video.src = fileUrl;
                 this._video.load();
                 this._video.load();
                 this._video.addEventListener('loadedmetadata', () => {
                 this._video.addEventListener('loadedmetadata', () => {
@@ -642,6 +657,17 @@ function arozcastApp() {
             this._video.muted = muted;
             this._video.muted = muted;
         },
         },
 
 
+        _setRepeat(mode) {
+            this.repeatMode = mode;
+            // Native browser loop only for single-track repeat — the browser
+            // suppresses the 'ended' event when loop=true, which is fine for 'one'
+            // (no sender action needed). For 'all', the sender drives playlist
+            // advancement via media.ended → nextTrack, so we must NOT set loop=true.
+            const loop = (mode === 'one');
+            this._audio.loop = loop;
+            this._video.loop = loop;
+        },
+
         _stop() {
         _stop() {
             this._audio.pause();
             this._audio.pause();
             this._audio.removeAttribute('src');
             this._audio.removeAttribute('src');

+ 5 - 0
src/web/Musicify/musicify.js

@@ -990,6 +990,9 @@ function musicifyApp() {
             var idx = modes.indexOf(this.repeat);
             var idx = modes.indexOf(this.repeat);
             this.repeat = modes[(idx + 1) % modes.length];
             this.repeat = modes[(idx + 1) % modes.length];
             ao_module_storage.setStorage("Musicify", "repeat", this.repeat);
             ao_module_storage.setStorage("Musicify", "repeat", this.repeat);
+            if (this.castMode) {
+                this._castSend('media.repeat', { mode: this.repeat });
+            }
         },
         },
 
 
         _onEnded() {
         _onEnded() {
@@ -1329,6 +1332,7 @@ function musicifyApp() {
                     } else {
                     } else {
                         self._castSend('media.pause', {});
                         self._castSend('media.pause', {});
                     }
                     }
+                    self._castSend('media.repeat', { mode: self.repeat });
                 }
                 }
 
 
                 // Heartbeat: tell Arozcast we are still here every 5 s
                 // Heartbeat: tell Arozcast we are still here every 5 s
@@ -1461,6 +1465,7 @@ function musicifyApp() {
             // will immediately sync currentTime to the live remote position.
             // will immediately sync currentTime to the live remote position.
             ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
             ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
             this._castSend('media.volume', { volume: this.volume, muted: this.isMuted });
             this._castSend('media.volume', { volume: this.volume, muted: this.isMuted });
+            this._castSend('media.repeat', { mode: this.repeat });
             clearInterval(this._castPingTimer); clearInterval(this._castWatchTimer);
             clearInterval(this._castPingTimer); clearInterval(this._castWatchTimer);
             this._castPingTimer = setInterval(function() { self._castSend('peer.heartbeat', {}); }, 5000);
             this._castPingTimer = setInterval(function() { self._castSend('peer.heartbeat', {}); }, 5000);
             this._castWatchTimer = setInterval(function() {
             this._castWatchTimer = setInterval(function() {