mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Text widgets: stop losing text off the bottom, and show an edit without an app restart
Two separate faults in the same widget, both reported on #234. 1. Text taller than the screen vanished in silence. renderText set overflow:hidden on the document with nothing able to scroll it, so anything past the bottom edge was simply gone: "Text goes to bottom and disappears. It dont fit." The content now gets a wrapper and an overflow mode: fit (default) shrink until it fits — a NO-OP when the content already fits, so it rescues widgets that are currently losing text without changing ones that are fine scroll pan through it on a loop with a pause at each end, for content genuinely longer than a screen where shrinking would make it unreadable clip the old behaviour, kept because a designer-positioned layout may deliberately run past the edge and must not be rescaled underneath its author Measuring runs after layout, after web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts arriving late is the classic cause of a fit computed against the wrong height. 2. Editing a widget did not reach the screen until the app was restarted. The render endpoint serves live config, but the player deliberately keeps a widget's WebView while its URL is unchanged (re-navigating every duration is a visible flash and destroys widget state — a half-typed directory search, scroll position). Editing changes the content, not the id, so the URL never changed and the reuse check always hit. The widget's updated_at now travels to the player as widget_rev and goes into the render URL, so the URL differs exactly when the content differs — and only then, so the anti-flash reuse still holds for untouched widgets. The rev is refreshed at send time rather than read from the published snapshot, because a widget edit does not republish the playlist. Editing a widget also now pushes to the displays showing it, instead of notifying nothing at all. 859 server tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
parent
452c286357
commit
4cc750ba3a
|
|
@ -876,8 +876,11 @@ 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) {
|
||||
// rev makes the URL change when — and only when — the widget's content changed, so an
|
||||
// edit reloads while an untouched widget still hits the no-flash reuse path.
|
||||
val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render" +
|
||||
(if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "")
|
||||
(if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "?d=") +
|
||||
"&rev=${item.widgetRev}"
|
||||
Log.i("MainActivity", "Playing widget fullscreen: $url")
|
||||
mediaPlayer.showWidget(url)
|
||||
wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ data class PlaylistItem(
|
|||
val remoteUrl: String? = null,
|
||||
val muted: Boolean = false,
|
||||
val widgetId: String? = null,
|
||||
// Changes whenever the widget is edited. Carried into the render URL so an edited widget gets
|
||||
// a URL the player has not seen, which is what defeats the deliberate same-URL WebView reuse.
|
||||
val widgetRev: Long = 0L,
|
||||
val widgetType: String? = null,
|
||||
val schedules: List<ScheduleEval.Block> = emptyList(),
|
||||
// feat/transition-engine: the resolved GL transition this item plays INTO (null = hard cut).
|
||||
|
|
@ -169,6 +172,7 @@ class PlaylistController(
|
|||
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
|
||||
muted = obj.optInt("muted", 0) == 1,
|
||||
widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null },
|
||||
widgetRev = obj.optLong("widget_rev", 0L),
|
||||
widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null },
|
||||
schedules = parseSchedules(obj.optJSONArray("schedules")),
|
||||
transition = Transitions.parse(obj.optJSONObject("transition"))
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ function buildSnapshotItems(playlistId) {
|
|||
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
|
||||
c.duration_sec as content_duration, c.remote_url, c.unstable_connection,
|
||||
c.captions_enabled, c.captions_lang, c.subtitle_url, c.subtitle_lang,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -216,7 +216,7 @@ router.get('/:id', requirePlaylistRead, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -277,7 +277,7 @@ router.post('/:id/publish', requirePlaylistWrite, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -327,7 +327,7 @@ router.post('/:id/discard', requirePlaylistWrite, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -352,7 +352,7 @@ router.get('/:id/items', requirePlaylistRead, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -471,7 +471,7 @@ router.post('/:id/items', requirePlaylistWrite, async (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -555,7 +555,7 @@ router.put('/:id/items/:itemId', requirePlaylistWrite, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -603,7 +603,7 @@ router.post('/:id/items/:itemId/duplicate', requirePlaylistWrite, (req, res) =>
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
@ -632,7 +632,7 @@ router.post('/:id/items/reorder', requirePlaylistWrite, (req, res) => {
|
|||
COALESCE(c.filename, w.name) as filename,
|
||||
c.mime_type, c.filepath, c.thumbnail_path,
|
||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
|
||||
FROM playlist_items pi
|
||||
LEFT JOIN content c ON pi.content_id = c.id
|
||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||
|
|
|
|||
|
|
@ -157,6 +157,30 @@ router.put('/:id', (req, res) => {
|
|||
if (name) db.prepare('UPDATE widgets SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id);
|
||||
if (config) db.prepare('UPDATE widgets SET config = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(JSON.stringify(config), req.params.id);
|
||||
|
||||
// Push the change to any display currently showing this widget. Editing a widget used to
|
||||
// notify nothing at all: the render endpoint serves live config, but a player that already has
|
||||
// the widget on screen keeps its WebView (deliberately — re-navigating a widget every duration
|
||||
// is a visible flash and destroys widget state). With no push and no change to the URL, an edit
|
||||
// reached the screen only when the app was restarted. Reported on #234: "I changed the text and
|
||||
// the new text did not appear on the screen. I had to close the app and then open again."
|
||||
//
|
||||
// The push is what makes it prompt; the rev in the payload is what makes the player reload.
|
||||
try {
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||
const commandQueue = require('../lib/command-queue');
|
||||
const affected = db.prepare(`
|
||||
SELECT DISTINCT d.id FROM devices d
|
||||
JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
|
||||
WHERE pi.widget_id = ?
|
||||
`).all(req.params.id);
|
||||
for (const d of affected) {
|
||||
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* best-effort; the heartbeat refresh still picks it up */ }
|
||||
|
||||
res.json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
|
|
@ -405,6 +429,68 @@ function renderText(c) {
|
|||
html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
|
||||
return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
|
||||
});
|
||||
|
||||
// What to do when the text is taller than the screen. It used to be clipped in silence: the
|
||||
// document was overflow:hidden with no scrollbar and nothing to scroll it, so on a display
|
||||
// shorter than the content the bottom simply vanished — reported as "text goes to bottom and
|
||||
// disappears. It dont fit."
|
||||
//
|
||||
// fit (default) shrink until it fits. A no-op when the content already fits, so this
|
||||
// rescues widgets that are currently losing text without altering ones that are fine.
|
||||
// scroll pan through it on a loop, with a pause at each end. For content that is genuinely
|
||||
// longer than a screen, where shrinking it would make it unreadable.
|
||||
// clip the old behaviour, kept because a designer-positioned layout may deliberately run
|
||||
// past the edge and must not be rescaled underneath the author.
|
||||
const overflowMode = ['fit', 'scroll', 'clip'].includes(c.overflow) ? c.overflow : 'fit';
|
||||
|
||||
// Runs inside the sandboxed iframe (allow-scripts, null origin). Measures after layout, after
|
||||
// web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts
|
||||
// loading late is the classic cause of a fit that was computed against the wrong height.
|
||||
const fitScript = overflowMode === 'clip' ? '' : `<script>
|
||||
(function () {
|
||||
var mode = ${JSON.stringify(overflowMode)};
|
||||
var wrap = document.getElementById('st-wrap');
|
||||
if (!wrap) return;
|
||||
var anim = null;
|
||||
function apply() {
|
||||
// Reset before measuring, or we measure the previous transform's result.
|
||||
wrap.style.transform = '';
|
||||
if (anim) { anim.cancel(); anim = null; }
|
||||
var avail = document.documentElement.clientHeight;
|
||||
var need = wrap.scrollHeight;
|
||||
if (!avail || !need || need <= avail + 1) return; // already fits: leave it alone
|
||||
if (mode === 'fit') {
|
||||
var k = avail / need;
|
||||
wrap.style.transformOrigin = 'top center';
|
||||
wrap.style.transform = 'scale(' + k + ')';
|
||||
return;
|
||||
}
|
||||
// scroll: hold, pan the overflow, hold, return. Speed is distance-based so a long
|
||||
// document is not unreadably fast and a short one is not tediously slow.
|
||||
var over = need - avail;
|
||||
var panMs = Math.max(4000, (over / 40) * 1000);
|
||||
var holdMs = 2000;
|
||||
var total = panMs * 2 + holdMs * 2;
|
||||
var p1 = holdMs / total, p2 = (holdMs + panMs) / total, p3 = (holdMs * 2 + panMs) / total;
|
||||
anim = wrap.animate(
|
||||
[
|
||||
{ transform: 'translateY(0)', offset: 0 },
|
||||
{ transform: 'translateY(0)', offset: p1 },
|
||||
{ transform: 'translateY(' + (-over) + 'px)', offset: p2 },
|
||||
{ transform: 'translateY(' + (-over) + 'px)', offset: p3 },
|
||||
{ transform: 'translateY(0)', offset: 1 },
|
||||
],
|
||||
{ duration: total, iterations: Infinity, easing: 'linear' }
|
||||
);
|
||||
}
|
||||
addEventListener('resize', apply);
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(apply).catch(function(){});
|
||||
// Late images change the height too; rAF lets first layout finish before measuring.
|
||||
addEventListener('load', function () { requestAnimationFrame(apply); });
|
||||
requestAnimationFrame(apply);
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
// Security: c.html / c.css are intentionally raw user-authored content, but the
|
||||
// render is public and same-origin with the dashboard - injected <script> could
|
||||
// otherwise read the dashboard's localStorage JWT. Render the user content inside
|
||||
|
|
@ -413,8 +499,11 @@ function renderText(c) {
|
|||
const inner = `<!DOCTYPE html><html><head><style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html, body { width:100vw; height:100vh; overflow:hidden; }
|
||||
/* The wrapper is what gets scaled or panned. It must be allowed to exceed the viewport,
|
||||
otherwise there is nothing to measure and nothing to move. */
|
||||
#st-wrap { width:100%; min-height:100%; will-change:transform; }
|
||||
${c.css || ''}
|
||||
</style></head><body>${html}</body></html>`;
|
||||
</style></head><body><div id="st-wrap">${html}</div>${fitScript}</body></html>`;
|
||||
return `<!DOCTYPE html><html><head><style>
|
||||
* { margin:0; padding:0; }
|
||||
html, body { width:100vw; height:100vh; overflow:hidden; background:${safeCss(c.background, 'transparent')}; }
|
||||
|
|
|
|||
|
|
@ -186,6 +186,23 @@ function resolveGroupSync(device, deviceId) {
|
|||
return { group_id: group.id, is_leader: leaderId === deviceId };
|
||||
}
|
||||
|
||||
// A widget's CONTENT is always live — /api/widgets/:id/render reads the current config — but the
|
||||
// playlist payload is a snapshot taken at publish time, so a widget edited afterwards still carried
|
||||
// its published revision. The player keeps a widget's WebView while its URL is unchanged (re-
|
||||
// navigating a widget every duration is a visible flash and destroys widget state), so an unchanged
|
||||
// URL meant an edit only reached the screen after an app restart.
|
||||
//
|
||||
// Refreshing the rev here, at send time, makes the URL differ exactly when the content differs —
|
||||
// and only then, so the anti-flash reuse still holds for widgets nobody has touched.
|
||||
const widgetRevOf = db.prepare('SELECT updated_at FROM widgets WHERE id = ?').pluck();
|
||||
function refreshWidgetRevs(assignments) {
|
||||
if (!Array.isArray(assignments)) return;
|
||||
for (const a of assignments) {
|
||||
if (!a || !a.widget_id) continue;
|
||||
try { a.widget_rev = widgetRevOf.get(a.widget_id) ?? a.widget_rev ?? 0; } catch (_) { /* keep published */ }
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlaylistPayload(deviceId) {
|
||||
const device = db.prepare('SELECT playlist_id, layout_id, orientation, wall_id, timezone, reported_timezone FROM devices WHERE id = ?').get(deviceId);
|
||||
|
||||
|
|
@ -194,6 +211,7 @@ function buildPlaylistPayload(deviceId) {
|
|||
const playlist = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
|
||||
if (playlist?.published_snapshot) {
|
||||
try { assignments = JSON.parse(playlist.published_snapshot); } catch (e) { assignments = []; }
|
||||
refreshWidgetRevs(assignments);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue