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 d6451d1..7c0e714 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -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 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 9c6202c..60b5dfc 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 @@ -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() 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. 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 4ae5b3f..225f675 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 @@ -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) { diff --git a/tizen/js/app.js b/tizen/js/app.js index 4499a7a..fb3e4d8 100644 --- a/tizen/js/app.js +++ b/tizen/js/app.js @@ -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 '
' + k + '' + + (val == null || val === '' ? '—' : String(val)) + '
'; + } + o.innerHTML = '

ScreenTinker — Tizen Player

' + + 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 || ''; }); diff --git a/tizen/js/player.js b/tizen/js/player.js index 7b45c7d..fdf7b81 100644 --- a/tizen/js/player.js +++ b/tizen/js/player.js @@ -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) {