fix(player): apply per-item mute live on Android + web (#129)

Honor the new per-item mute from the server, both in real time and on reload.

Android:
- WebSocketService: onMuteChanged callback + main-thread device:mute-changed handler.
- MediaPlayerManager.setVideoMuted(): flips the live ExoPlayer volume on the current
  video (YouTube autoplays muted; images/widgets are silent).
- MainActivity: on device:mute-changed, apply immediately if the toggled item is the
  one playing now.
- PlaylistController.sig(): include muted so a published mute change re-renders/persists
  instead of being de-duped.

Web player (server/player/index.html):
- device:mute-changed handler toggles the current <video>; the video mount now also
  honors item.muted so a published mute sticks across reloads.

Tizen intentionally not included: its player mutes ALL video for autoplay, so per-item
unmute isn't achievable there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-18 16:51:41 -05:00
parent 332f4c5b5c
commit c55118d8d8
5 changed files with 41 additions and 2 deletions

View file

@ -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()
}

View file

@ -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). */

View file

@ -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 ?: "")
}

View file

@ -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", "")

View file

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