feat(players): proof-of-play on Android+Tizen; close Tizen parity gaps
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

Android and Tizen never emitted device:play-event, so Reports showed Total
Plays / Hours / proof-of-play as all zero for those devices (only the web
player logged plays). Both now emit play_start on show and play_end on
advance/teardown, mirroring the web player's contract (leader-gated for walls,
widget-id fallback so durations close). Server side unchanged — play_logs and
the reports queries were already waiting for the events.

Tizen parity with the web player (from the parity audit):
- audio: landscape <video> honors item.muted (warm-muted for autoplay, then
  applies the real state) instead of force-muting; wall followers stay silent
- device:mute-changed: real-time per-item mute of the on-screen video
- device:remote-key: D-pad/volume/mute/home, BACK=info overlay, POWER=screen-off
- device:remote-touch: normalized-coordinate touch injection
- buffered widget swap: reveal the new iframe on load then clear (no black flash)
- diagnostic info overlay toggled by the dashboard BACK key

Still muted on Tizen: the portrait AVPlay video path and transition-composited
video (would need webapis.avplay volume APIs / renderVideoBuffered work).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-07-22 09:22:18 -05:00
parent 2d4af97f67
commit 07419fee1f
5 changed files with 192 additions and 5 deletions

View file

@ -219,7 +219,15 @@ class MainActivity : AppCompatActivity() {
onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) },
// Screen-resilience: the defined "waiting for content" state — ONLY on a fresh device
// with nothing to show yet (never while content is on screen; that path keeps current).
onWaitingForContent = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) }
onWaitingForContent = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) },
// Proof-of-play: forward play_start/play_end to the server (device:play-event) so this
// device shows Total Plays / Hours in Reports. Widgets have no content_id, so key on the
// widget id instead — keeping play_start and play_end consistent so the row's duration closes.
onPlayLog = { event, item, completed ->
val cid = item.contentId.ifEmpty { item.widgetId ?: "" }
if (event == "play_start") wsService?.sendPlayStart(cid, item.filename, item.durationSec)
else wsService?.sendPlayEnd(cid, item.filename, completed)
}
)
// Screen-resilience: an item is playable only when its content is actually available —
// a widget, a remote stream, or a fully-downloaded local file. A not-yet/failed download is

View file

@ -36,7 +36,10 @@ class PlaylistController(
private val onNothingScheduled: (() -> Unit)? = null,
// Screen-resilience: the defined "content isn't downloaded yet" waiting state, shown ONLY when
// nothing has ever played (fresh device). Never used while content is on screen.
private val onWaitingForContent: (() -> Unit)? = null
private val onWaitingForContent: (() -> Unit)? = null,
// Proof-of-play: emitted on each item show ("play_start") and when it's left ("play_end"),
// so the caller can forward device:play-event to the server (populates play_logs / Reports).
private val onPlayLog: ((event: String, item: PlaylistItem, completed: Boolean) -> Unit)? = null
) {
private companion object {
const val CONTENT_RECHECK_MS = 3000L
@ -48,6 +51,9 @@ class PlaylistController(
private val items = mutableListOf<PlaylistItem>()
private var currentIndex = -1
// Proof-of-play: the item we last emitted a play_start for (so we can close it with play_end
// on the next show). Only set for loggable items (never wall followers).
private var loggedItem: PlaylistItem? = null
private val handler = Handler(Looper.getMainLooper())
private var advanceRunnable: Runnable? = null
private var isRunning = false
@ -286,6 +292,9 @@ class PlaylistController(
hasContentOnScreen = false
pendingItems = null
pendingSuccessorId = null
// Proof-of-play: close the open row so its duration is recorded on shutdown/screen-off.
loggedItem?.let { onPlayLog?.invoke("play_end", it, true) }
loggedItem = null
}
fun next() {
@ -332,6 +341,14 @@ class PlaylistController(
onItemChanged(item)
hasContentOnScreen = true // a valid item is now rendered — protect it from being blanked
// Proof-of-play (parity with the web player): close the outgoing item and open this one.
// Wall followers don't log — the leader's single row represents the whole wall.
if (!wallFollower) {
loggedItem?.let { prev -> if (prev !== item) onPlayLog?.invoke("play_end", prev, true) }
onPlayLog?.invoke("play_start", item, false)
loggedItem = item
}
// For images and widgets, auto-advance after duration. For videos, wait
// for the completion callback. Wall followers never auto-advance — the
// leader's wall:sync index drives every switch.

View file

@ -1095,6 +1095,38 @@ class WebSocketService : Service() {
} catch (e: Throwable) { Log.w("WebSocketService", "sendPlaybackState: ${e.message}") }
}
// Proof-of-play — parity with the web player's device:play-event (server/player/index.html).
// Without these, Android devices never populate the play_logs table, so Reports show
// Total Plays / Hours / proof-of-play as all zero for them. play_start INSERTs a row on show;
// play_end fills its duration on advance. Matches the server handler in ws/deviceSocket.js.
fun sendPlayStart(contentId: String, contentName: String, durationSec: Int) {
if (socket?.connected() != true) return
try {
val data = JSONObject().apply {
put("device_id", config.deviceId)
put("event", "play_start")
put("content_id", if (contentId.isEmpty()) JSONObject.NULL else contentId)
put("content_name", contentName)
put("duration_sec", if (durationSec > 0) durationSec else JSONObject.NULL)
}
socket?.emit("device:play-event", data)
} catch (e: Throwable) { Log.w("WebSocketService", "sendPlayStart: ${e.message}") }
}
fun sendPlayEnd(contentId: String, contentName: String, completed: Boolean) {
if (socket?.connected() != true) return
try {
val data = JSONObject().apply {
put("device_id", config.deviceId)
put("event", "play_end")
put("content_id", if (contentId.isEmpty()) JSONObject.NULL else contentId)
put("content_name", contentName)
put("completed", completed)
}
socket?.emit("device:play-event", data)
} catch (e: Throwable) { Log.w("WebSocketService", "sendPlayEnd: ${e.message}") }
}
// Video-wall senders. Guarded on socket.connected() like sendPlaybackState, so a
// pre-register tick is a no-op (the server would reject it as unauthenticated).
fun emitWallSync(wallId: String, currentIndex: Int, contentId: String?, positionSec: Float) {

View file

@ -410,6 +410,44 @@
socket.on('device:remote-start', function () { startStreaming(); });
socket.on('device:remote-stop', function () { stopStreaming(); });
// Dashboard remote control (parity with the web player) — touch injection + D-pad/volume/mute keys
// + real-time per-item mute. All wrapped so a malformed payload can never wedge the socket.
socket.on('device:remote-touch', function (data) {
try {
if (!data) return;
var x = (data.x || 0) * elStage.offsetWidth, y = (data.y || 0) * elStage.offsetHeight;
var el = document.elementFromPoint(x, y);
if (el && el.click) el.click();
} catch (e) {}
});
socket.on('device:remote-key', function (data) {
try {
if (!data) return;
var v = player.getCurrentVideo();
var n = player.getItemCount();
switch (data.keycode) {
case 'KEYCODE_DPAD_RIGHT': player.advance(); break;
case 'KEYCODE_DPAD_LEFT': if (n > 0) player.gotoIndex((player.getIndex() - 1 + n) % n); break;
case 'KEYCODE_DPAD_CENTER':
case 'KEYCODE_ENTER': if (v) { if (v.paused) v.play(); else v.pause(); } break;
case 'KEYCODE_VOLUME_UP': if (v && !player.isWallFollower()) { v.volume = Math.min(1, v.volume + 0.1); v.muted = false; } break;
case 'KEYCODE_VOLUME_DOWN': if (v) { v.volume = Math.max(0, v.volume - 0.1); } break;
case 'KEYCODE_MENU': if (v && !(player.isWallFollower() && v.muted)) { v.muted = !v.muted; } break;
case 'KEYCODE_HOME': if (n > 0) player.gotoIndex(0); break;
case 'KEYCODE_BACK': toggleInfoOverlay(); break;
case 'KEYCODE_POWER': if (document.getElementById('screenOffOverlay')) clearScreenOff(); else showScreenOff(); break;
}
} catch (e) {}
});
// #129 real-time per-item mute — apply immediately if the toggled item is the one on screen now.
socket.on('device:mute-changed', function (data) {
try {
var item = player.getCurrentItem();
var v = player.getCurrentVideo();
if (data && item && data.content_id && item.content_id === data.content_id && v) v.muted = !!data.muted;
} catch (e) {}
});
// ---- video wall sync (mirrors the web player) ----
// Leader broadcasts position; followers align index + drift-correct their video.
socket.on('wall:sync', function (d) { wallController.onSync(d); });
@ -477,6 +515,31 @@
var o = document.getElementById('screenOffOverlay');
if (o && o.parentNode) o.parentNode.removeChild(o);
}
// Diagnostic info overlay (parity with the web player). Toggled by the dashboard remote BACK key —
// NOT the physical TV BACK (10009), which still exits to setup. A quick on-site troubleshooting panel.
function toggleInfoOverlay() {
var existing = document.getElementById('infoOverlay');
if (existing) { if (existing.parentNode) existing.parentNode.removeChild(existing); return; }
var item = (typeof player !== 'undefined' && player) ? player.getCurrentItem() : null;
var o = document.createElement('div');
o.id = 'infoOverlay';
o.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.82);color:#e6e6e6;' +
'font:16px/1.7 sans-serif;padding:6vh 6vw;box-sizing:border-box';
function row(k, val) {
return '<div><span style="color:#8ab4f8;display:inline-block;min-width:210px">' + k + '</span>' +
(val == null || val === '' ? '—' : String(val)) + '</div>';
}
o.innerHTML = '<h2 style="margin:0 0 14px;color:#fff">ScreenTinker — Tizen Player</h2>' +
row('Device ID', deviceId) +
row('Server', serverUrl) +
row('App version', APP_VERSION) +
row('Connection', (socket && socket.connected) ? 'online' : 'offline') +
row('Orientation', (player && player.orientation) || 'landscape') +
row('Now playing', item ? (item.filename || item.widget_id || item.content_id) : 'idle') +
row('Playlist position', player ? ((player.getIndex() + 1) + ' / ' + player.getItemCount()) : '—') +
row('Screen', (screen.width + '×' + screen.height));
document.body.appendChild(o);
}
// #109: report PiP show/clear over the existing device:log channel (tag 'pip') so it
// surfaces in the dashboard device log. Used as the PipOverlay log callback.
function reportPip(level, msg) {
@ -587,6 +650,10 @@
// ---- playback ----
var player = new PlaylistPlayer(elStage, function () { return serverUrl.replace(/\/+$/, ''); }, function () { return deviceId || ''; });
// Proof-of-play: forward the player's device:play-event to the server (populates play_logs / Reports).
player.onPlayEvent = function (payload) {
try { if (socket && socket.connected && deviceId) socket.emit('device:play-event', payload); } catch (e) {}
};
// Multi-zone layout renderer (matches the Android player). app.js picks the renderer
// per playlist-update from payload.layout; the two never run at once.
var zoneRenderer = new ZoneRenderer(elStage, function () { return serverUrl.replace(/\/+$/, ''); }, function () { return deviceId || ''; });

View file

@ -49,6 +49,11 @@ function PlaylistPlayer(stageEl, getBase, getDeviceId) {
// #157 deferred rotation-out: when a removed-but-live item should finish before we swap in the list.
this._deferredRotation = false;
this._deferredSuccessorId = null;
// Proof-of-play (parity with the web/Android players): onPlayEvent is a hook set by app.js that
// forwards device:play-event to the server (populates play_logs / Reports). _loggedItem is the item
// we last emitted play_start for, so we can close it with play_end on the next show.
this.onPlayEvent = null;
this._loggedItem = null;
}
// #157 continuity helpers (mirror the web/Android players).
@ -203,6 +208,8 @@ PlaylistPlayer.prototype.stop = function () {
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
this._releasePreloadImage(); // #187: drop any warmed next-image bitmap on teardown
this.clearStage();
// Proof-of-play: close the open row so its duration is recorded on teardown.
if (this._loggedItem) { this._logPlay('play_end', this._loggedItem, true); this._loggedItem = null; }
};
PlaylistPlayer.prototype.clearStage = function () {
@ -279,6 +286,8 @@ PlaylistPlayer.prototype.setTimezone = function (tz) { this.timezone = tz || nul
PlaylistPlayer.prototype.setWallFollower = function (b) { this.wallFollower = !!b; };
PlaylistPlayer.prototype.invalidate = function () { this.sig = ''; };
PlaylistPlayer.prototype.getIndex = function () { return this.index; };
PlaylistPlayer.prototype.getItemCount = function () { return this.items.length; };
PlaylistPlayer.prototype.isWallFollower = function () { return !!this.wallFollower; };
PlaylistPlayer.prototype.getCurrentItem = function () { return this.items[this.index] || null; };
PlaylistPlayer.prototype.getCurrentVideo = function () { return this.currentVideoEl; };
PlaylistPlayer.prototype.getItemStartedAt = function () { return this.itemStartedAt; };
@ -341,6 +350,23 @@ PlaylistPlayer.prototype.nothingScheduled = function () {
this.timer = setTimeout(function () { self.startPlayback(); }, 30000);
};
// Proof-of-play: build + forward a device:play-event payload via the onPlayEvent hook (set by app.js).
// Widgets carry no content_id, so key on widget_id — keeping play_start/play_end consistent so the
// row's duration closes. Mirrors server/player/index.html and the Android WebSocketService.
PlaylistPlayer.prototype._logPlay = function (event, item, completed) {
if (typeof this.onPlayEvent !== 'function' || !item) return;
var cid = item.content_id || item.widget_id || '';
var payload = {
device_id: this.getDeviceId(),
event: event,
content_id: cid || null,
content_name: item.filename || 'Unknown'
};
if (event === 'play_start') payload.duration_sec = (item.duration_sec > 0 ? item.duration_sec : null);
else payload.completed = !!completed;
try { this.onPlayEvent(payload); } catch (e) {}
};
PlaylistPlayer.prototype.playCurrent = function () {
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
if (!this.items.length) { this.idle(); return; }
@ -349,6 +375,15 @@ PlaylistPlayer.prototype.playCurrent = function () {
this.currentVideoEl = null; // set by renderVideo when applicable
var item = this.items[this.index];
// Proof-of-play (parity with the web/Android players): close the outgoing item and open this one.
// Wall followers don't log — the leader's single row represents the whole wall.
if (!this.wallFollower) {
if (this._loggedItem && this._loggedItem !== item) this._logPlay('play_end', this._loggedItem, true);
this._logPlay('play_start', item, false);
this._loggedItem = item;
}
// Scheduled playlists cycle even with one active item so windows re-evaluate.
// A wall FOLLOWER also behaves "single": it holds the leader's current item
// (looping, no auto-advance) and only switches when wall:sync says the index moved.
@ -362,10 +397,13 @@ PlaylistPlayer.prototype.playCurrent = function () {
&& !(item.widget_id && !item.content_id)
&& mime.indexOf('video/') !== 0
&& mime.indexOf('image/') === 0;
// Widgets also buffer-swap (renderWidget reveals the new iframe on load, then clears), so they skip
// the pre-dispatch clearStage too — kills the black flash on directory-board/widget reloads.
var isWidget = !!(item.widget_id && !item.content_id);
// Skip the pre-dispatch clearStage for an image (it decode-gates + swaps inside renderImage) AND for a
// landscape video that will composite into a wipe (renderVideoBuffered needs the outgoing frame to
// capture as `from`, then clears inside its own mount). Everything else clears up front as before.
if (!isImage && !this._videoWillComposite(item)) this.clearStage();
if (!isImage && !isWidget && !this._videoWillComposite(item)) this.clearStage();
try {
if (mime === 'video/youtube') return this.renderYouTube(item, single);
@ -750,7 +788,7 @@ PlaylistPlayer.prototype.renderVideo = function (item, single) {
var v = pre || document.createElement('video');
this.currentVideoEl = v; // wall: leader reads currentTime; follower drift-corrects this
this.fit(v, item);
v.autoplay = true; v.muted = true; v.setAttribute('playsinline', '');
v.autoplay = true; v.muted = true; v.setAttribute('playsinline', ''); // warm muted so autoplay is guaranteed
v.loop = single; // single item loops; multi advances on end
v.onended = function () { if (!single) self.advance(); };
v.onerror = function () { self.skipSoon(); };
@ -758,6 +796,12 @@ PlaylistPlayer.prototype.renderVideo = function (item, single) {
v.style.cssText = ''; // clear the offscreen-hide style if reused
this.stage.appendChild(v);
var p = v.play(); if (p && p.catch) p.catch(function () {});
// Audio parity (#129): honor per-item mute. Warm-play stays muted so autoplay can't be blocked,
// then apply the real state once playing (Tizen is a privileged app, so unmuted playback is fine).
// Wall followers stay muted — only the audio leader is unmuted by the dashboard/remote.
var applyMute = function () { try { v.muted = self.wallFollower ? true : !!item.muted; } catch (e) {} };
v.addEventListener('playing', applyMute, { once: true });
if (!v.paused && v.readyState >= 2) applyMute(); // a reused preload may already be playing
// Safety net: if 'ended' never fires (rare), advance after the known
// content duration (or the assignment duration) + a buffer.
if (!single) {
@ -776,8 +820,27 @@ PlaylistPlayer.prototype.renderYouTube = function (item, single) {
};
PlaylistPlayer.prototype.renderWidget = function (item, single) {
var self = this;
var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '');
this.renderFrame(src, single ? 0 : this.durationMs(item));
// Anti-flash (#directory-board, parity with the web player): build the new iframe hidden ON TOP of the
// current content and reveal it on load, THEN drop everything else — so a widget/directory-board
// reload never black-flashes the stage (playCurrent skipped the pre-clear for widgets).
var f = document.createElement('iframe');
f.setAttribute('frameborder', '0');
f.setAttribute('allowfullscreen', '');
f.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;border:0;opacity:0';
var revealed = false;
var reveal = function () {
if (revealed) return; revealed = true;
var kids = self.stage.children;
for (var i = kids.length - 1; i >= 0; i--) { if (kids[i] !== f) self.stage.removeChild(kids[i]); }
f.style.opacity = '1';
};
f.addEventListener('load', reveal, { once: true });
setTimeout(reveal, 4000); // fallback: reveal even if a blocked widget never fires load
f.src = src;
this.stage.appendChild(f);
if (!single) this.schedule(this.durationMs(item));
};
PlaylistPlayer.prototype.renderFrame = function (src, advanceMs, allow, vertical) {