mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
feat(players): proof-of-play on Android+Tizen; close Tizen parity gaps
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:
parent
2d4af97f67
commit
07419fee1f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 || ''; });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue