diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index 1d86f93..e26dcd6 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -562,6 +562,16 @@ class MainActivity : AppCompatActivity() { wsService?.onPipShow = { data -> if (::pipOverlay.isInitialized) pipOverlay.show(data) } wsService?.onPipClear = { data -> if (::pipOverlay.isInitialized) pipOverlay.clearFrom(data) } + // #129: real-time mute. Apply immediately if the toggled item is the one playing now; + // otherwise it's already persisted server-side and lands via the next playlist update. + wsService?.onMuteChanged = { data -> + val contentId = if (data.isNull("content_id")) "" else data.optString("content_id", "") + val current = playlistController.currentContentId ?: "" + if (contentId.isNotEmpty() && contentId == current && ::mediaPlayer.isInitialized) { + mediaPlayer.setVideoMuted(data.optBoolean("muted", false)) + } + } + wsService?.onRegistered = { _ -> hideStatus() } diff --git a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt index 66b506a..6bbede9 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt @@ -184,6 +184,15 @@ class MediaPlayerManager( fun isPlayingVideo(): Boolean = currentType == MediaType.VIDEO && (exoPlayer?.isPlaying == true) + // #129: live per-item mute. Applies a dashboard mute toggle to the CURRENTLY playing + // video in real time (decoupled from a playlist reload). Only video carries audio here + // — YouTube embeds autoplay muted and images/widgets are silent — so this targets the + // ExoPlayer volume. Persistence across the next play comes from the playlist payload's + // per-item `muted` (honored in playVideo). Main thread only. + fun setVideoMuted(muted: Boolean) { + if (currentType == MediaType.VIDEO) exoPlayer?.volume = if (muted) 0f else 1f + } + // ---- Video-wall (wall:sync) accessors. All must be called on the main thread. ---- /** Current video position in ms (0 when no video). */ diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt index 126a09e..b9d8a6c 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt @@ -109,7 +109,10 @@ class PlaylistController( // widget items share an empty contentId). // #74/#75: a schedule edit changes playback even when content is identical, so // the change signature must include schedules (else updated blocks are dropped). - fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + + // #129: include muted too, so a mute-only change (same content) re-renders with the + // new flag instead of being de-duped (the real-time event handles the live toggle; + // this makes a published mute persist across reloads). + fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + (if (it.muted) "m" else "") + "|" + it.schedules.joinToString(";") { b -> b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "") } diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index c64da3a..59a047c 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -44,6 +44,7 @@ class WebSocketService : Service() { var onWallSyncRequest: ((JSONObject) -> Unit)? = null var onPipShow: ((JSONObject) -> Unit)? = null var onPipClear: ((JSONObject) -> Unit)? = null + var onMuteChanged: ((JSONObject) -> Unit)? = null inner class LocalBinder : Binder() { fun getService(): WebSocketService = this@WebSocketService @@ -248,6 +249,12 @@ class WebSocketService : Service() { handler.post { try { onPipClear?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onPipClear cb: ${e.message}") } } } + // #129: real-time mute toggle. Post to the main thread — it touches the player. + safeOn("device:mute-changed") { args -> + val data = args.firstOrNull() as? JSONObject ?: return@safeOn + handler.post { try { onMuteChanged?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onMuteChanged cb: ${e.message}") } } + } + safeOn("device:command") { args -> val data = args.firstOrNull() as? JSONObject ?: return@safeOn val type = data.optString("type", "") diff --git a/server/db/database.js b/server/db/database.js index 6e0d10c..efb559d 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -148,6 +148,10 @@ const migrations = [ // playlist_items conversion (migrateAssignmentsToPlaylists) dropped this // column. Column ADD is idempotent via the surrounding try/catch loop. "ALTER TABLE playlist_items ADD COLUMN zone_id TEXT REFERENCES layout_zones(id) ON DELETE SET NULL", + // #129: per-item mute. The legacy `assignments` table had a muted column, but the + // active device payload is built from playlist_items -> published_snapshot, which never + // carried it, so the dashboard mute toggle was a no-op end to end. + "ALTER TABLE playlist_items ADD COLUMN muted INTEGER NOT NULL DEFAULT 0", // Slice 1: idempotency guard for the one-time signup welcome/admin emails. // Non-null = this user has already been handled, so we never double-send. // New signups are stamped with the real unix-seconds time the send block ran diff --git a/server/db/schema.sql b/server/db/schema.sql index 542984f..d0bcfcb 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -367,6 +367,7 @@ CREATE TABLE IF NOT EXISTS playlist_items ( zone_id TEXT REFERENCES layout_zones(id) ON DELETE SET NULL, sort_order INTEGER NOT NULL DEFAULT 0, duration_sec INTEGER NOT NULL DEFAULT 10, + muted INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); diff --git a/server/player/index.html b/server/player/index.html index a81dd91..2cd63c5 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -932,6 +932,15 @@ if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); } }); + // #129: real-time mute. Apply immediately if the toggled item is the one playing now; + // the value is also persisted in the snapshot so it sticks on the next playlist load. + socket.on('device:mute-changed', (data) => { + const item = playlist[currentIndex]; + if (data && item && data.content_id && item.content_id === data.content_id && currentVideoEl) { + try { currentVideoEl.muted = !!data.muted; } catch (_) {} + } + }); + // #109: PiP overlay — a pushed floating layer above the playlist. The player // fetches uri itself (same trust model as remote_url content). socket.on('device:pip-show', (data) => pipShow(data)); @@ -1637,7 +1646,8 @@ video.autoplay = true; // Followers stay muted unconditionally (leader-only audio); leaders // start muted only if the user hasn't gestured yet (autoplay policy). - video.muted = isFollower ? true : !userHasInteracted; + // #129: a per-item mute (set in the admin console) also forces muted. + video.muted = isFollower ? true : (!userHasInteracted || !!item.muted); // Explicit max volume on the leader so audio is at full level when // unmute happens (default is 1.0 but make it visible in logs). if (!isFollower) video.volume = 1.0; diff --git a/server/routes/assignments.js b/server/routes/assignments.js index 1397940..7e9a838 100644 --- a/server/routes/assignments.js +++ b/server/routes/assignments.js @@ -44,7 +44,7 @@ function ensureDevicePlaylist(deviceId, userId) { // Standard item query with joined content/widget info const ITEM_SELECT = ` - SELECT pi.id, pi.playlist_id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec, + SELECT pi.id, pi.playlist_id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec, pi.muted, pi.created_at, pi.updated_at, COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, @@ -139,12 +139,28 @@ function checkItemWrite(req, res) { return item; } +// #129: real-time mute. Tell every device on this playlist to toggle the volume of the +// matching currently-playing item NOW (decoupled from publish — the device matches by +// content_id/widget_id and applies it live). The new value is also written to the row, so +// it lands in the next published snapshot and persists across playlist reloads. +function emitMuteChanged(req, item, muted) { + try { + const io = req.app.get('io'); + if (!io) return; + const deviceNs = io.of('/device'); + const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(item.playlist_id); + const payload = { content_id: item.content_id || null, widget_id: item.widget_id || null, muted: !!muted }; + for (const d of devices) deviceNs.to(d.id).emit('device:mute-changed', payload); + console.log(`[mute] item ${item.id} (content ${item.content_id || item.widget_id}) -> ${muted ? 'MUTED' : 'unmuted'}; notified ${devices.length} device(s)`); + } catch (e) { /* best-effort live toggle; the published snapshot is the source of truth */ } +} + // Update playlist item router.put('/:id', (req, res) => { const item = checkItemWrite(req, res); if (!item) return; - const { sort_order, duration_sec, zone_id } = req.body; + const { sort_order, duration_sec, zone_id, muted } = req.body; const updates = []; const values = []; @@ -153,12 +169,17 @@ router.put('/:id', (req, res) => { // zone_id can be null (clear the zone) - treat undefined as "no change", // any other value (including null) as "write this". if (zone_id !== undefined) { updates.push('zone_id = ?'); values.push(zone_id || null); } + // #129: per-item mute (coerced to 0/1). Was silently dropped here before, so the + // dashboard toggle did nothing. + const mutedChanged = muted !== undefined && (item.muted ? 1 : 0) !== (muted ? 1 : 0); + if (muted !== undefined) { updates.push('muted = ?'); values.push(muted ? 1 : 0); } if (updates.length > 0) { updates.push("updated_at = strftime('%s','now')"); values.push(req.params.id); db.prepare(`UPDATE playlist_items SET ${updates.join(', ')} WHERE id = ?`).run(...values); markDraft(item.playlist_id); + if (mutedChanged) emitMuteChanged(req, item, muted ? 1 : 0); } const updated = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(req.params.id); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index c886636..76e057c 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -66,7 +66,7 @@ function requirePlaylistWrite(req, res, next) { // Build the snapshot item list for a playlist (denormalized for device payload) function buildSnapshotItems(playlistId) { const items = db.prepare(` - SELECT pi.id AS _iid, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec, + SELECT pi.id AS _iid, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec, pi.muted, COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size, c.duration_sec as content_duration, c.remote_url, w.name as widget_name, w.widget_type, w.config as widget_config diff --git a/server/test/mute.test.js b/server/test/mute.test.js new file mode 100644 index 0000000..034c9a0 --- /dev/null +++ b/server/test/mute.test.js @@ -0,0 +1,100 @@ +'use strict'; + +// #129 mute: the per-item `muted` flag must persist on PUT /api/assignments/:id, come +// back in the item read (ITEM_SELECT), and reach the device by being included in the +// playlist's published_snapshot (buildSnapshotItems). Before the fix the PUT silently +// dropped `muted`, playlist_items had no such column, and the snapshot never carried it — +// so the dashboard mute toggle was a no-op end to end. + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const Database = require('better-sqlite3'); + +const PORT = 3994; +const BASE = `http://127.0.0.1:${PORT}`; +const DATA_DIR = path.join(os.tmpdir(), 'st-mute-test-' + crypto.randomBytes(4).toString('hex')); +const LOG = path.join(os.tmpdir(), 'st-mute-' + crypto.randomBytes(4).toString('hex') + '.log'); +const PW = 'Passw0rd123'; +let proc, db; +const S = {}; + +async function jfetch(p, opts = {}) { + const res = await fetch(BASE + p, opts); + let body = null; try { body = await res.json(); } catch { /* non-JSON */ } + return { status: res.status, body }; +} +const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' } }); +const post = (tok, obj) => ({ method: 'POST', ...auth(tok), body: JSON.stringify(obj || {}) }); +const put = (tok, obj) => ({ method: 'PUT', ...auth(tok), body: JSON.stringify(obj || {}) }); + +before(async () => { + const logFd = fs.openSync(LOG, 'w'); + proc = spawn('node', ['server.js'], { + cwd: path.join(__dirname, '..'), + env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, + stdio: ['ignore', logFd, logFd], + }); + let up = false; + for (let i = 0; i < 80; i++) { + try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ } + await new Promise((r) => setTimeout(r, 250)); + } + if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000)); + + const reg = await jfetch('/api/auth/register', post(null, { email: 'm' + crypto.randomBytes(4).toString('hex') + '@x.local', password: PW })); + S.jwt = reg.body.token; + const pl = await jfetch('/api/playlists', post(S.jwt, { name: 'mute-pl' })); + S.playlistId = pl.body.id; + + // Seed a content row + a playlist_item on a single connection (avoids WAL visibility + // races; FK off so a NULL-workspace content row is fine for the test). + db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'), { timeout: 5000 }); + db.pragma('foreign_keys = OFF'); + S.contentId = crypto.randomUUID(); + db.prepare("INSERT INTO content (id, filename, filepath, mime_type, file_size, remote_url) VALUES (?,?,?,?,0,?)") + .run(S.contentId, 'clip', '', 'video/mp4', 'https://example.com/clip.mp4'); + const info = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?,?,0,10)') + .run(S.playlistId, S.contentId); + S.itemId = info.lastInsertRowid; +}); + +after(() => { + try { db.close(); } catch { /* */ } + try { proc.kill('SIGKILL'); } catch { /* */ } + for (const f of [DATA_DIR, LOG]) { try { fs.rmSync(f, { recursive: true, force: true }); } catch { /* */ } } +}); + +test('PUT /assignments/:id persists muted and returns it (ITEM_SELECT)', async () => { + const on = await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { muted: true })); + assert.equal(on.status, 200); + assert.equal(on.body.muted, 1, 'muted persisted + returned as 1'); + + const off = await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { muted: false })); + assert.equal(off.status, 200); + assert.equal(off.body.muted, 0, 'unmute persisted + returned as 0'); +}); + +test('muted reaches the device via the published snapshot (buildSnapshotItems)', async () => { + await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { muted: true })); + const pub = await jfetch(`/api/playlists/${S.playlistId}/publish`, post(S.jwt, {})); + assert.equal(pub.status, 200); + + const snapRow = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(S.playlistId); + const snap = JSON.parse(snapRow.published_snapshot); + const item = snap.find((i) => i.content_id === S.contentId); + assert.ok(item, 'the item is in the published snapshot'); + assert.equal(item.muted, 1, 'snapshot (device payload) carries muted=1'); +}); + +test('PUT ignoring muted (other field) leaves muted untouched', async () => { + await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { muted: true })); + const r = await jfetch(`/api/assignments/${S.itemId}`, put(S.jwt, { duration_sec: 15 })); + assert.equal(r.status, 200); + assert.equal(r.body.muted, 1, 'a non-mute update does not reset muted'); + assert.equal(r.body.duration_sec, 15); +});