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/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;