From 335681b90724c8933323705791b51bbaff045701 Mon Sep 17 00:00:00 2001 From: screentinker Date: Fri, 17 Jul 2026 20:17:21 -0500 Subject: [PATCH] feat(directory-board): panel-ring scroll + in-place refresh + per-device frame diagnostic (#203) Compositor panel-ring board scroll (smooth on Blink+Gecko, no blank-on-refresh), a per-device frame-rate diagnostic widget + dashboard card, and web/Android/Tizen device-id passthrough to widget render URLs. --- .../com/remotedisplay/player/MainActivity.kt | 7 +- .../player/player/ZoneManager.kt | 10 +- frontend/js/views/device-detail.js | 67 +++ server/player/index.html | 34 +- server/routes/widgets.js | 397 +++++++++++++----- server/server.js | 2 + tizen/js/app.js | 4 +- tizen/js/player.js | 10 +- 8 files changed, 403 insertions(+), 128 deletions(-) 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 2490451..e6c8fb6 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -545,13 +545,13 @@ class MainActivity : AppCompatActivity() { playerView.visibility = View.GONE imageView.visibility = View.GONE zoneManager?.setupZones(layoutZones, layoutId) - zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache) + zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache, config.deviceId) zoneManager?.lastAssignmentSig = assignmentSig } } else if (changed) { Log.i("MainActivity", "Multi-zone assignments changed, re-rendering") handler.post { - zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache) + zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache, config.deviceId) zoneManager?.lastAssignmentSig = assignmentSig } } else { @@ -832,7 +832,8 @@ class MainActivity : AppCompatActivity() { // layouts; multi-zone widgets go through ZoneManager). Previously unhandled, // so widgets were blank/broken in default-fullscreen and the fullscreen template. if (item.isWidget) { - val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render" + val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render" + + (if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "") Log.i("MainActivity", "Playing widget fullscreen: $url") mediaPlayer.showWidget(url) wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f) diff --git a/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt b/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt index 260ba7a..135b479 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt @@ -45,6 +45,7 @@ class ZoneManager( private var zones = listOf() // Render context kept for rotation re-renders. private var renderServerUrl = "" + private var renderDeviceId = "" // appended to widget render URLs so widgets can report per-device private var renderCache: com.remotedisplay.player.data.ContentCache? = null var currentLayoutId: String? = null @@ -77,7 +78,7 @@ class ZoneManager( Log.i(TAG, "Setup ${zones.size} zones") } - fun renderAssignments(assignments: JSONArray, serverUrl: String, contentCache: com.remotedisplay.player.data.ContentCache) { + fun renderAssignments(assignments: JSONArray, serverUrl: String, contentCache: com.remotedisplay.player.data.ContentCache, deviceId: String = "") { // Clear ONLY our own zone views/timers. `container` is the activity root and // also holds the static playerView/imageView/youtubeWebView/statusOverlay - // removeAllViews() here would detach those and black the screen on switch-back. @@ -86,13 +87,14 @@ class ZoneManager( zoneViews.clear() releaseExoPlayers() renderServerUrl = serverUrl + renderDeviceId = deviceId renderCache = contentCache val containerWidth = container.width val containerHeight = container.height if (containerWidth == 0 || containerHeight == 0) { // Container not laid out yet, retry after layout. - container.post { renderAssignments(assignments, serverUrl, contentCache) } + container.post { renderAssignments(assignments, serverUrl, contentCache, deviceId) } return } @@ -205,7 +207,9 @@ class ZoneManager( widgetType != null -> { val widgetId = a.optString("widget_id", "") val webView = createWebView() - webView.loadUrl("$renderServerUrl/api/widgets/$widgetId/render") + val wUrl = "$renderServerUrl/api/widgets/$widgetId/render" + + (if (renderDeviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(renderDeviceId) else "") + webView.loadUrl(wUrl) webView.layoutParams = params container.addView(webView); zoneViews[zone.id] = webView if (multi) scheduleZoneAdvance(zone.id, durationMs, advance) diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index e4b57f5..b2df054 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -11,6 +11,7 @@ let screenshotHandler = null; let playbackHandler = null; let logHandler = null; let shellHandler = null; +let diagPollTimer = null; // polls a diag-smoothness widget's reported frame stats while the page is open let screenshotInterval = null; let remoteActive = false; @@ -172,6 +173,7 @@ async function loadDevice(deviceId, activeTab = null) { const device = await api.getDevice(deviceId); currentDevice = device; const latestTelemetry = device.telemetry?.[0] || {}; + const diagWidget = (device.assignments || []).find(a => a && a.widget_type === 'diag-smoothness'); contentEl.innerHTML = `
@@ -290,6 +292,7 @@ async function loadDevice(deviceId, activeTab = null) {
+ ${diagWidget ? renderDiagPanel(diagWidget) : ''}
${t('device.info.status')}
@@ -614,6 +617,8 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.terminal.push_apk_hint')}
` : ''} `; + // If this device is assigned the smoothness-diagnostic widget, poll THIS device's reported stats. + if (diagWidget) startDiagPoll(diagWidget.widget_id, deviceId); // Hydrate authenticated thumbnail images in the playlist tab const pc = document.getElementById('playlistContainer'); if (pc) hydrateAuthImages(pc); @@ -1870,7 +1875,69 @@ function updateTelemetryDisplay(telemetry) { if (telemetry.cpu_usage != null) update('telCpu', telemetry.cpu_usage.toFixed(1) + '%'); } +// ----- diag-smoothness widget: show the frame stats it reports from the panel ----- +function renderDiagPanel(w) { + return ` +
+
+
Frame-rate diagnostic${w.widget_name ? ' · ' + esc(w.widget_name) : ''}
+ waiting for panel… +
+
+
FPS
--
+
Refresh
--
+
Long frames
--
+
Worst stall
--
+
+
The panel running this widget reports its frame timing here every few seconds.
+
+
`; +} + +function startDiagPoll(widgetId, deviceId) { + if (diagPollTimer) clearInterval(diagPollTimer); + const url = `/api/widgets/${encodeURIComponent(widgetId)}/telemetry?device=${encodeURIComponent(deviceId)}`; + const tick = async () => { + try { + const res = await fetch(url, { cache: 'no-store' }); + updateDiagPanel(res.ok ? await res.json() : null); + } catch (e) { /* transient — keep last shown */ } + }; + tick(); + diagPollTimer = setInterval(tick, 2500); +} + +function updateDiagPanel(d) { + const v = document.getElementById('diagVerdict'); + if (!v) { if (diagPollTimer) { clearInterval(diagPollTimer); diagPollTimer = null; } return; } // navigated away + const set = (id, val, color) => { const el = document.getElementById(id); if (el) { el.textContent = val; if (color) el.style.color = color; } }; + if (!d) { + v.textContent = 'no report yet'; v.style.color = 'var(--text-muted)'; + set('diagMeta', 'No data received yet — make sure the panel is showing this widget, then give it a few seconds.'); + return; + } + const age = d.receivedAt ? Math.max(0, Math.round((Date.now() - d.receivedAt) / 1000)) : null; + const stale = age != null && age > 15; + const verdict = d.verdict || 'measuring'; + const vcolor = verdict === 'SMOOTH' ? 'var(--success)' : (verdict === 'STALLING' ? 'var(--danger)' : 'var(--text-muted)'); + // If we haven't heard from THIS panel recently, say so plainly rather than showing stale numbers as live. + if (stale) { v.textContent = 'no live report'; v.style.color = 'var(--text-muted)'; } + else { v.textContent = verdict; v.style.color = vcolor; } + set('diagFps', d.fps != null ? String(d.fps) : '--'); + set('diagHz', d.refreshHz != null ? d.refreshHz + ' Hz' : '--'); + set('diagLong', d.longFrames != null ? String(d.longFrames) : '--', d.longFrames > 0 ? 'var(--danger)' : 'var(--success)'); + set('diagWorst', d.worstStallMs != null ? d.worstStallMs + ' ms' : '--', d.worstStallMs > 50 ? 'var(--danger)' : 'var(--text)'); + const meta = []; + if (d.vp) meta.push('viewport ' + d.vp); + if (d.dpr) meta.push('dpr ' + d.dpr); + if (d.elapsedS != null) meta.push('running ' + d.elapsedS + 's'); + if (age != null) meta.push('last report ' + age + 's ago'); + set('diagMeta', meta.join(' · ')); + set('diagStalls', Array.isArray(d.recent) && d.recent.length ? ('recent stalls: ' + d.recent.join(' ')) : ''); +} + export function cleanup() { + if (diagPollTimer) { clearInterval(diagPollTimer); diagPollTimer = null; } if (statusHandler) off('device-status', statusHandler); if (screenshotHandler) off('screenshot-ready', screenshotHandler); if (playbackHandler) off('playback-state', playbackHandler); diff --git a/server/player/index.html b/server/player/index.html index a2d3872..92009a2 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -2111,7 +2111,7 @@ discardPendingSwap(); const iframe = document.createElement('iframe'); - iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render`; + iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`; // Positioned + sized by the `#playerContainer > iframe` CSS rule. Hidden while it // loads so its black background never shows over the outgoing content. iframe.style.background = '#000'; @@ -2137,6 +2137,19 @@ pendingWidgetSwap = { iframe, timer: setTimeout(discardPendingSwap, WIDGET_SWAP_TIMEOUT_MS) }; } + // A held widget/board is left mounted (it self-refreshes its own data in place — no iframe + // reload, so its scroll is never reset). This just re-checks the schedule on the slow cadence: + // if it's still the sole active item, leave the live iframe alone and re-arm; if its daypart + // closed or a sibling opened, hand off to nextItem (idle or a genuine buffered transition — + // schedule-awareness / Fix A preserved). + function reevaluateHeldWidget() { + if (nextActiveIndex(currentIndex) === currentIndex) { + advanceTimer = setTimeout(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS); + return; + } + nextItem(); + } + function renderContent(item) { // Cancel any pending advance/refresh timer up front so a prior item's timer (incl. a // self-rescheduling widget refresh) can't fire against the new content. @@ -2150,14 +2163,15 @@ renderWidgetBuffered(item); // Group members run no local timer (their schedule tick drives the index). if (!groupSync) { - // Advance via nextItem in BOTH cases so the schedule is re-evaluated every cycle - // (a closed daypart / newly-active sibling is honored) — never a bespoke loop that - // re-renders blind to the schedule. A solo/held board (nextActiveIndex === current) - // just does it on a slow cadence: nextItem re-selects this same item and re-renders - // it through the buffered swap (data refresh, no flash). A rotating playlist advances - // on the item's duration. + // A solo/held widget (e.g. a directory board) self-refreshes its own data IN PLACE, + // so we must NOT reload its iframe on a timer — that would reset its scroll. Instead, + // on the slow cadence just re-check the SCHEDULE and only transition if the active + // selection changed (daypart opened/closed). A rotating playlist advances normally on + // its duration; the first mount + every genuine transition still go through the + // buffered swap. const held = nextActiveIndex(currentIndex) === currentIndex; - advanceTimer = setTimeout(nextItem, held ? WIDGET_SOLO_REFRESH_MS : (item.duration_sec || 30) * 1000); + if (held) advanceTimer = setTimeout(reevaluateHeldWidget, WIDGET_SOLO_REFRESH_MS); + else advanceTimer = setTimeout(nextItem, (item.duration_sec || 30) * 1000); } return; } @@ -2299,7 +2313,7 @@ if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000); } else if (item.widget_id) { const iframe = document.createElement('iframe'); - iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render`; + iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`; iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000'; iframe.allow = 'autoplay; fullscreen'; // Sandbox into a unique origin so widget scripts can't read window.parent @@ -2413,7 +2427,7 @@ // Android player, which keys off the assignment's widget_type. if (a.widget_id) { const iframe = document.createElement('iframe'); - iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render`; + iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`; // Sandbox into a unique origin so widget scripts can't read window.parent // state (localStorage / JWT). allow-scripts keeps inline widget code running. iframe.setAttribute('sandbox', 'allow-scripts'); diff --git a/server/routes/widgets.js b/server/routes/widgets.js index 67e8cab..0f80ad6 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -168,7 +168,7 @@ router.delete('/:id', (req, res) => { res.json({ success: true }); }); -const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','social','directory-board','directory-search']); +const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','social','directory-board','directory-search','diag-smoothness']); function renderWidgetHtml(type, config) { config = config || {}; switch (type) { @@ -180,6 +180,7 @@ function renderWidgetHtml(type, config) { case 'social': return renderSocial(config); case 'directory-board': return renderDirectoryBoard(config); case 'directory-search': return renderDirectorySearch(config); + case 'diag-smoothness': return renderDiagSmoothness(config); default: return '

Unknown widget

'; } } @@ -221,6 +222,35 @@ router.get('/:id/data.json', (req, res) => { res.json({ categories }); }); +// Latest frame-rate telemetry per widget, reported by the diag-smoothness widget running on a device. +// In-memory (diagnostic, not persisted) — a device page reads the snapshot for the widget it plays. +const widgetTelemetry = new Map(); +// Public POST from the widget: it runs in a null-origin sandboxed iframe, so this must be no-auth + +// CORS-open. The widget sends text/plain (a "simple" request → no CORS preflight); we JSON.parse it. +router.post('/:id/telemetry', express.text({ type: '*/*', limit: '16kb' }), (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + let t = {}; + try { t = typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body || {}); } catch (e) { t = {}; } + t.receivedAt = Date.now(); + // Key by the reporting device (player passes ?device=) so multiple panels don't collide; + // fall back to a widget-scoped key for players that don't pass a device id yet. + const key = (t.device && String(t.device).slice(0, 64)) || ('w:' + req.params.id); + widgetTelemetry.set(key, t); + res.json({ ok: true }); +}); +// Public GET so the dashboard device page can display the snapshot. ?device= reads that panel's +// report; without it (or if that panel hasn't reported) falls back to the widget-scoped snapshot. +router.get('/:id/telemetry', (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'no-store'); + const dev = req.query.device ? String(req.query.device) : null; + // Device-scoped request returns ONLY that device's report — NO widget-wide fallback, or one + // reporting panel's data would show on every other device's page (incl. offline ones). A request + // with no device id gets the widget-scoped snapshot (raw/debug view only). + const rec = dev ? (widgetTelemetry.get(dev) || null) : (widgetTelemetry.get('w:' + req.params.id) || null); + res.json(rec); +}); + // Preview unsaved widget from config (used by editor Preview button) router.post('/preview', (req, res) => { const { widget_type, config } = req.body || {}; @@ -449,9 +479,7 @@ function renderDirectoryBoard(c) { mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 40px), transparent 100%); -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 40px), transparent 100%); } - .track { position:absolute; top:0; left:0; right:0; will-change: transform; } - .block { padding:0 48px 24px; } - .block + .block { padding-top:24px; } + .track { position:absolute; top:0; left:0; right:0; } .category { padding:36px 0 16px; } .category h2 { @@ -484,8 +512,6 @@ function renderDirectoryBoard(c) { body.light .entry { color:#1a1a2e; } body.light .entry.available, body.light .entry.available .id { color:#059669; } - .gap { height:120px; } - @media (max-width: 1280px) { .header h1 { font-size:54px; } .header img.logo { max-height:120px; } @@ -501,7 +527,6 @@ function renderDirectoryBoard(c) {
-
@@ -512,9 +537,9 @@ function renderDirectoryBoard(c) { var SPEEDS = { slow: 20, medium: 45, fast: 75 }; if (cfg.theme === 'light') document.body.classList.add('light'); - var GAP_PX = 120; // MUST match the .gap element height (set inline below) — the scroll loop - // translates by baseH+GAP_PX, so any mismatch jumps that many px each cycle. + var GAP_PX = 120; // blank space between the end of the directory and where it repeats (loop seam) var MIN_SCROLL_PX_SEC = 5; // anti-burn-in minimum when content fits + var REFRESH_MS = 60000; // poll data.json this often; re-render ONLY when entries changed // ----- header ----- var header = document.getElementById('header'); @@ -579,118 +604,167 @@ function renderDirectoryBoard(c) { var cols = cfg.columns || 'auto'; if (['auto','1','2','3','4'].indexOf(String(cols)) === -1) cols = 'auto'; - function buildBlock() { - var block = document.createElement('div'); - block.className = 'block'; - var cats = Array.isArray(cfg.categories) ? cfg.categories : []; - cats.forEach(function(cat){ - var catEl = document.createElement('div'); - catEl.className = 'category'; - var h2 = document.createElement('h2'); - h2.textContent = cat.name || ''; - catEl.appendChild(h2); - var entries = document.createElement('div'); - entries.className = 'entries'; - entries.setAttribute('data-cols', String(cols)); - (cat.entries || []).forEach(function(e){ - var row = document.createElement('div'); - row.className = 'entry' + (e.available ? ' available' : ''); - var id = document.createElement('span'); - id.className = 'id'; - id.textContent = (e.identifier || '') + ':'; - var text = document.createElement('div'); - text.className = 'text'; - var nm = document.createElement('span'); - nm.className = 'nm'; - nm.textContent = e.name || ''; - text.appendChild(nm); - if (e.subtitle) { - var sub = document.createElement('span'); - sub.className = 'sub'; - sub.textContent = e.subtitle; - text.appendChild(sub); - } - row.appendChild(id); - row.appendChild(text); - entries.appendChild(row); - }); - catEl.appendChild(entries); - block.appendChild(catEl); - }); - return block; - } - - var track = document.getElementById('track'); - var baseBlock = buildBlock(); - track.appendChild(baseBlock); - - // ----- measure + clone enough copies to fill (seamless loop) ----- - function setupScroll() { - // remove any previous clones (on resize) - while (track.children.length > 1) track.removeChild(track.lastChild); - var gap = document.createElement('div'); - gap.className = 'gap'; - gap.style.height = GAP_PX + 'px'; // MUST equal GAP_PX — the loop translates by baseH+GAP_PX; - track.appendChild(gap); // a mismatch (was CSS 120 vs GAP_PX 100) jumps 20px each cycle. - - var baseH = baseBlock.getBoundingClientRect().height; - var cycleH = baseH + GAP_PX; // distance to translate per loop - var viewH = scroller.getBoundingClientRect().height || window.innerHeight; - - // Clone enough times so track fills scroller + at least one full cycle - // Minimum 1 clone (so we can loop). Target: track_height >= view + cycle. - var cloneCount = Math.max(1, Math.ceil((viewH + cycleH) / cycleH)); - for (var i = 0; i < cloneCount; i++) { - track.appendChild(buildBlock()); - if (i < cloneCount - 1) { - var g = document.createElement('div'); - g.className = 'gap'; - g.style.height = GAP_PX + 'px'; // keep every clone-gap == GAP_PX (seamless loop) - track.appendChild(g); + function buildCategoryEl(cat) { + var catEl = document.createElement('div'); + catEl.className = 'category'; + var h2 = document.createElement('h2'); + h2.textContent = cat.name || ''; + catEl.appendChild(h2); + var entries = document.createElement('div'); + entries.className = 'entries'; + entries.setAttribute('data-cols', String(cols)); + (cat.entries || []).forEach(function(e){ + var row = document.createElement('div'); + row.className = 'entry' + (e.available ? ' available' : ''); + var id = document.createElement('span'); + id.className = 'id'; + id.textContent = (e.identifier || '') + ':'; + var text = document.createElement('div'); + text.className = 'text'; + var nm = document.createElement('span'); + nm.className = 'nm'; + nm.textContent = e.name || ''; + text.appendChild(nm); + if (e.subtitle) { + var sub = document.createElement('span'); + sub.className = 'sub'; + sub.textContent = e.subtitle; + text.appendChild(sub); } - } - - // speed - var contentFits = baseH <= viewH; - var speedName = cfg.scroll_speed || 'medium'; - var speedPxSec = SPEEDS[speedName] || SPEEDS.medium; - if (contentFits) speedPxSec = MIN_SCROLL_PX_SEC; - - var duration = cycleH / speedPxSec; - - // inject keyframes - var oldStyle = document.getElementById('scroll-kf'); - if (oldStyle) oldStyle.remove(); - var style = document.createElement('style'); - style.id = 'scroll-kf'; - style.textContent = - '@keyframes dir-scroll { from { transform: translateY(0); } to { transform: translateY(-' + cycleH + 'px); } }' + - '.track { animation: dir-scroll ' + duration + 's linear infinite; }'; - document.head.appendChild(style); + row.appendChild(id); + row.appendChild(text); + entries.appendChild(row); + }); + catEl.appendChild(entries); + return catEl; } - // wait for images (logo + bgs) to load before measuring, so heights are correct + var stage = scroller; // the clip window between header & footer + var N = 4; // panels in the ring (2 tile the screen, 1 dwells below, 1 above) + var baseStyle = document.createElement('style'); + baseStyle.textContent = + '.panel{ position:absolute; left:0; right:0; top:0; overflow:hidden; contain:paint; will-change:transform; backface-visibility:hidden; }' + + '.pcontent{ position:absolute; left:0; right:0; top:0; padding:0 48px; }'; + document.head.appendChild(baseStyle); + var scrollStyle = document.createElement('style'); + scrollStyle.id = 'dir-scroll-kf'; + document.head.appendChild(scrollStyle); + + // ----- scroll: a ring of compositor-animated, viewport-tall panels ----- + // Animating one tall track fails on Firefox (it won't composite a transform bigger than ~1.1x the + // viewport / 4096px and falls back to a stuttering main-thread animation) and churns GPU tiles even + // on Chromium. Instead we run N panels, each exactly one stage-height tall (overflow:hidden + + // contain:paint clamp each compositor layer to that box). Each panel is a static window onto a full + // copy of the directory (positioned by a static inner translateY = -slice); the PANEL is slid + // rigidly upward by ONE CSS @keyframes animation, and the panels are phase-locked by negative + // animation-delay so two always tile the screen while one dwells off-screen below and one above. + // There is NO per-frame JS — "scrolling" is the compositor sliding pre-rasterized viewport-sized + // textures, so nothing on the main thread (GC, extensions, the host player) can stutter it. On each + // off-screen wrap a panel jumps its slice N screens ahead (content already built — nothing to load + // when it reappears) and, if a data refresh is pending, rebuilds its content THEN, safely off-screen. + var panels = []; // [{el, content, version, slice}] + var Sh = 0; // panel / stage height + var C = 0; // looped directory height (one full copy) + var speedPxSec = 0; + var contentVersion = 0; + var pending = null; // a queued data refresh, picked up per-panel while off-screen + + function fillContent(el) { // full directory + a clone of the top (>= one screen) for the within-panel wrap + var arr = Array.isArray(cfg.categories) ? cfg.categories : []; + arr.forEach(function(c){ el.appendChild(buildCategoryEl(c)); }); + var full = el.scrollHeight; // == C (one full directory) + var i = 0, guard = arr.length * 4 + 1; + while ((el.scrollHeight - full) < Sh + 4 && arr.length && i < guard) { + el.appendChild(buildCategoryEl(arr[i % arr.length])); i++; + } + return full; + } + + function globalScroll() { return speedPxSec * ((document.timeline.currentTime || 0) / 1000); } + function mod(a, n) { return n > 0 ? ((a % n) + n) % n : 0; } + function setSlice(p, off) { p.slice = off; p.content.style.transform = 'translate3d(0,' + (-off) + 'px,0)'; } + + function seedSlices() { // four consecutive screens, matching the lanes' physical phase (delays 0..-3T) + var base = globalScroll(); + var laneStart = [2 * Sh, 1 * Sh, 0, -1 * Sh]; + panels.forEach(function(p, i){ setSlice(p, mod(base + laneStart[i % 4], C)); }); + } + + function onWrap(p) { // fires as a panel wraps to the bottom (off-screen); rebuild + advance N screens + if (pending && p.version !== pending.version) { + p.content.replaceChildren(); + C = fillContent(p.content); // all panels share the same data => same C + p.version = pending.version; + } + setSlice(p, mod(p.slice + N * Sh, C)); + } + + function setup() { + layoutScroller(); + Sh = stage.getBoundingClientRect().height || window.innerHeight; + stage.replaceChildren(); + panels = []; + for (var i = 0; i < N; i++) { + var el = document.createElement('div'); el.className = 'panel'; el.setAttribute('data-lane', i); + el.style.height = Sh + 'px'; + var content = document.createElement('div'); content.className = 'pcontent'; + el.appendChild(content); + stage.appendChild(el); + panels.push({ el: el, content: content, version: contentVersion, slice: 0 }); + } + C = fillContent(panels[0].content); + for (var j = 1; j < N; j++) fillContent(panels[j].content); + speedPxSec = (C <= Sh) ? MIN_SCROLL_PX_SEC : (SPEEDS[cfg.scroll_speed] || SPEEDS.medium); + var T = Sh / speedPxSec, dur = N * T; + var kf = '@keyframes dir-pan { from { transform: translate3d(0,' + (2 * Sh) + 'px,0); } to { transform: translate3d(0,' + (-2 * Sh) + 'px,0); } }'; + kf += '.panel{ animation: dir-pan ' + dur + 's linear infinite; }'; + for (var k = 0; k < N; k++) kf += '.panel[data-lane="' + k + '"]{ animation-delay: ' + (-k * T).toFixed(4) + 's; }'; + scrollStyle.textContent = kf; + seedSlices(); + panels.forEach(function(p){ p.el.addEventListener('animationiteration', function(){ onWrap(p); }); }); + } + + // wait for images (logo + bgs) to load before the first layout, so heights are correct var pendingImgs = Array.from(document.images).filter(function(i){ return !i.complete; }); if (pendingImgs.length === 0) { - setupScroll(); + setup(); } else { - var done = 0; + var built = false, build = function(){ if (!built) { built = true; setup(); } }; pendingImgs.forEach(function(i){ - var onDone = function(){ done++; if (done === pendingImgs.length) setupScroll(); }; - i.addEventListener('load', onDone, { once:true }); - i.addEventListener('error', onDone, { once:true }); + i.addEventListener('load', build, { once:true }); + i.addEventListener('error', build, { once:true }); }); - // hard timeout so we never hang - setTimeout(function(){ if (document.getElementById('scroll-kf') == null) setupScroll(); }, 5000); + setTimeout(build, 5000); // hard timeout so we never hang } - // re-layout on resize (debounced) + // re-layout on resize (debounced) — rebuild the ring; globalScroll() keeps the same content position var rT; window.addEventListener('resize', function(){ clearTimeout(rT); - rT = setTimeout(function(){ layoutScroller(); setupScroll(); }, 250); + rT = setTimeout(setup, 250); }); + // ----- live data refresh: poll data.json; re-render ONLY when the entries changed ----- + // Mirrors the directory-search poll. data.json is THIS board's own feed (relative URL, + // CORS-open, no-store). Diff the categories signature and rebuild IN PLACE only on a real + // change, so an unchanged poll never touches the running scroll (no periodic reset). + var lastSig = JSON.stringify(cfg.categories || []); + setInterval(function(){ + if (document.hidden) return; + fetch('data.json', { cache: 'no-store' }) + .then(function(r){ return r.ok ? r.json() : Promise.reject(r.status); }) + .then(function(data){ + var cats = data && Array.isArray(data.categories) ? data.categories : []; + var sig = JSON.stringify(cats); + if (sig === lastSig) return; // unchanged -> leave the scroll running untouched + lastSig = sig; + cfg.categories = cats; + contentVersion++; // queue it; each panel adopts it on its next off-screen wrap + pending = { version: contentVersion }; + }) + .catch(function(){ /* transient error -> keep last-good board */ }); + }, REFRESH_MS); + // ----- pixel shift (anti-burn-in): every 5 min, shift .page 0-3px random dir ----- var page = document.getElementById('page'); setInterval(function(){ @@ -989,4 +1063,115 @@ function renderDirectorySearch(c) { `; } +// diag-smoothness: a self-contained frame-cadence tester for the ACTUAL panel. Two GPU-composited +// animations (a vertical scroll like the board + a fast sweep) plus a big on-screen HUD (FPS, refresh +// estimate, long-frame count, worst stall, SMOOTH/STALLING verdict) — so a stutter can be read off the +// panel screen with no console. If this stalls on real signage hardware, the hardware is the cause. +function renderDiagSmoothness(config) { + return `Smoothness Diagnostic +

Panel Smoothness Diagnostic

Two GPU-composited animations, zero app logic. If the scroll or the yellow bar skips — or the HUD reads STALLING — this panel/hardware is dropping frames.

+
+
TEST 1 · vertical scroll
+
TEST 2 · fast sweep
+
+
measuring…collecting frames
+
+
FPS now
+
Refresh est.
Hz
+
Long frames
0 >50ms
+
Worst stall
0 ms
+
+
no stalls yet · a healthy panel shows 0 long frames
+
+
+ `; +} + module.exports = router; diff --git a/server/server.js b/server/server.js index c0156c8..fab582e 100644 --- a/server/server.js +++ b/server/server.js @@ -560,6 +560,8 @@ const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS } = require('./config/a // BEFORE their parent router mount so the _skipAuth bypass / the limiter fire first. app.get('/api/widgets/:id/render', (req, res, next) => { req._skipAuth = true; next(); }); app.get('/api/widgets/:id/data.json', (req, res, next) => { req._skipAuth = true; next(); }); +app.post('/api/widgets/:id/telemetry', (req, res, next) => { req._skipAuth = true; next(); }); // diag widget reports frame stats (null-origin iframe) +app.get('/api/widgets/:id/telemetry', (req, res, next) => { req._skipAuth = true; next(); }); app.get('/api/widgets/preview-session/:id', (req, res, next) => { req._skipAuth = true; next(); }); app.use('/api/widgets/preview', rateLimit(60000, 30)); // base64 inline = memory-intensive app.use('/api/widgets/preview-session', rateLimit(60000, 30)); // preview session creation retains rendered HTML in memory for 5min diff --git a/tizen/js/app.js b/tizen/js/app.js index d194827..4499a7a 100644 --- a/tizen/js/app.js +++ b/tizen/js/app.js @@ -586,10 +586,10 @@ } // ---- playback ---- - var player = new PlaylistPlayer(elStage, function () { return serverUrl.replace(/\/+$/, ''); }); + var player = new PlaylistPlayer(elStage, function () { return serverUrl.replace(/\/+$/, ''); }, function () { return deviceId || ''; }); // 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(/\/+$/, ''); }); + var zoneRenderer = new ZoneRenderer(elStage, function () { return serverUrl.replace(/\/+$/, ''); }, function () { return deviceId || ''; }); // #162: player and zoneRenderer SHARE the single #stage node. Track who currently owns it so we // only blank the OTHER renderer when actually switching modes — never on a same-mode unchanged // update, which (combined with each renderer's unchanged-sig short-circuit) used to leave the diff --git a/tizen/js/player.js b/tizen/js/player.js index cc39889..b1d45f8 100644 --- a/tizen/js/player.js +++ b/tizen/js/player.js @@ -18,9 +18,10 @@ var TIZEN_I18N = { var TZ_LANG = (function () { try { return (localStorage.getItem('rd_lang') || navigator.language || 'en').split('-')[0]; } catch (e) { return 'en'; } })(); function tzt(k) { return (TIZEN_I18N[TZ_LANG] && TIZEN_I18N[TZ_LANG][k]) || TIZEN_I18N.en[k] || k; } -function PlaylistPlayer(stageEl, getBase) { +function PlaylistPlayer(stageEl, getBase, getDeviceId) { this.stage = stageEl; this.getBase = getBase; + this.getDeviceId = getDeviceId || function () { return ''; }; this.items = []; this.index = 0; this.timer = null; @@ -547,7 +548,7 @@ PlaylistPlayer.prototype.renderYouTube = function (item, single) { }; PlaylistPlayer.prototype.renderWidget = function (item, single) { - var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render'; + var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : ''); this.renderFrame(src, single ? 0 : this.durationMs(item)); }; @@ -582,9 +583,10 @@ PlaylistPlayer.prototype.youtubeId = function (url) { * the FIRST zone only. Single-zone playback stays in PlaylistPlayer; app.js chooses the * renderer from payload.layout. */ -function ZoneRenderer(stageEl, getBase) { +function ZoneRenderer(stageEl, getBase, getDeviceId) { this.stage = stageEl; this.getBase = getBase; + this.getDeviceId = getDeviceId || function () { return ''; }; this.timezone = null; this.zones = []; this.timers = {}; // zoneId -> timeout id @@ -744,7 +746,7 @@ ZoneRenderer.prototype.showItem = function (zone, list, index) { zone.el.appendChild(zrFrame(ysrc, 'autoplay; encrypted-media', yvert)); if (multi) this.scheduleAdvance(zone, dur, advance); } else if (a.widget_type || (a.widget_id && !a.content_id)) { - zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render')); + zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : ''))); if (multi) this.scheduleAdvance(zone, dur, advance); } else if (mime.indexOf('video/') === 0) { var v = document.createElement('video');