Commit graph

339 commits

Author SHA1 Message Date
ScreenTinker 0cfa09046c feat(tizen): Samsung Tizen TV web player (.wgt)
Ports the ScreenTinker player to a Tizen TV / signage web app, speaking the
SAME /device socket.io protocol as the Android player — no server changes; a
Tizen display pairs from the same dashboard.

- app.js: device protocol client — register (pairing_code | device_id+token),
  device:registered/paired/unpaired/playlist-update, 15s heartbeat, keep-awake.
  Always reaches the server prompt until the display is actually paired; a
  saved-but-unreachable server falls back to the prompt (no blank screen); BACK
  returns to it.
- player.js: fullscreen single-zone renderer — image (duration timer), video
  (play-to-end + loop), YouTube (iframe embed), widget (iframe render endpoint).
- config.xml: Tizen TV manifest; build-wgt.sh packages (signs if Tizen CLI
  present, else unsigned); README covers URL-Launcher and signed-.wgt deploy.

Validated: headless protocol test vs the live server passed end-to-end
(register -> pair -> reconnect-auth -> playlist(2) -> content 200); loads +
renders in Chromium with no JS errors.

Not yet ported (fullscreen single-zone covers most signage): multi-zone, video
walls, screenshots, remote control, self-OTA. .wgt is a build artifact (gitignored).
2026-06-09 19:01:58 -05:00
screentinker c20b5b9b6f
Merge pull request #68 from screentinker/feat/android-boot-launch-tv
feat(android): reliable boot-launch incl. Android TV (1.7.11)
2026-06-09 17:45:05 -05:00
ScreenTinker d9d7a8ae0f feat(android): reliable boot-launch incl. Android TV (1.7.11)
The player has a launcher (category.HOME) + a boot receiver, but auto-start was
unreliable where you can't set a home launcher (Android TV) and on Android 14+,
where USE_FULL_SCREEN_INTENT is auto-revoked for non-calling apps so the boot
full-screen launcher silently no-ops.

Boot launch:
- BootReceiver now does a direct background startActivity when 'display over other
  apps' (SYSTEM_ALERT_WINDOW) is granted — a real exception to the bg-activity-launch
  restriction, and the one path that works on Android TV. Full-screen-intent
  notification kept as a fallback (locked screen / no overlay).
- Boot notification moved to a dedicated HIGH-importance channel (full-screen
  intents are only honored from one), and it auto-dismisses once the UI is up.

Setup screen — new permission rows so operators can grant what boot-launch needs:
- Launch on Boot (USE_FULL_SCREEN_INTENT, shown on Android 14+)
- Background Activity (battery-optimization exemption)
- Display Over Apps (SYSTEM_ALERT_WINDOW)
Made the screen scrollable and ~50% smaller text/buttons so all rows + Continue
fit on one screen (incl. landscape signage). Install-Unknown-Apps subtitle now
states updates are signature-verified, so it doesn't read as 'install anything'.

Verified end-to-end on an Android 16 emulator: after reboot the app auto-launched
(Direct launch via overlay) and the boot notice cleared itself; all rows toggle.
2026-06-09 17:44:49 -05:00
screentinker acd93377e7
Merge pull request #67 from screentinker/fix/android-ota-install-completion
fix(android): OTA install completion + kiosk auto-confirm (1.7.10)
2026-06-09 16:14:12 -05:00
ScreenTinker 5e3408be9a fix(android): OTA install never completed; auto-confirm for kiosks (1.7.10)
The OTA downloaded + verified the new APK and committed a PackageInstaller
session, but never handled STATUS_PENDING_USER_ACTION (which Android 13+ returns
for non-device-owner installers) — so the session stalled and the update never
installed. Reproduced on an Android 13 emulator: device stayed on the old version.

- UpdateChecker: register a receiver for the session's INSTALL_COMPLETE broadcast;
  on PENDING_USER_ACTION launch the system confirm dialog (and log SUCCESS).
- PowerAccessibilityService: when the package-installer dialog appears, auto-click
  the confirm button (by id, then label) so unattended kiosk screens update
  without a human tap. Scoped strictly to the package installer.

Verified end-to-end on Android 13: device auto-updated 1.7.10 -> 1.7.11 with no
interaction (receiver launched the dialog, accessibility confirmed it). Ships as
1.7.10 (also carries the Android 14+ crash + YouTube 152 fixes).

NOTE: existing 1.7.7 devices still need a one-time manual reinstall to reach a
build that has this fix; from 1.7.10 onward OTA is fully automatic.
2026-06-09 16:14:08 -05:00
screentinker 91cf7ebee6
Merge pull request #66 from screentinker/release/android-1.7.9
release(android): 1.7.9 — Android 14+ crash + YouTube 152 fixes
2026-06-09 15:41:56 -05:00
ScreenTinker f392292b9e release(android): 1.7.9 — Android 14+ crash + YouTube 152 fixes
Rebuilds and redistributes the player APK so the fixes actually reach devices:
- #5 Android 14+ mediaProjection FGS crash (committed in source but the SERVED
  ScreenTinker.apk was a stale 1.7.7 build from before it — modern devices
  couldn't launch the app at all).
- YouTube error 152 (embed base domain).

versionCode 11->12, versionName 1.7.8->1.7.9, VERSION file 1.7.7->1.7.9 so the
update check offers it; signed with the same release key (OTA signature check
passes). Verified on a Pixel 10 / Android 16 emulator: launches without crashing,
YouTube plays.
2026-06-09 15:41:52 -05:00
screentinker 64975fec88
Merge pull request #65 from screentinker/fix/android-youtube-embed-152
fix(android): YouTube error 152 — embed under a third-party domain (#4)
2026-06-09 15:36:49 -05:00
ScreenTinker 4572963175 fix(android): YouTube error 152 - embed under a third-party domain, not youtube.com
The player loaded the YouTube embed via loadDataWithBaseURL with base
https://www.youtube.com, so the embedding page claimed to BE youtube.com hosting
a youtube.com iframe. YouTube rejects that as an invalid embed context -> 'This
video is unavailable / Error 152 - 4' for every video (reproduced on a Pixel 10
/ Android 16 emulator with multiple known-embeddable videos).

Load the embed under a real third-party domain (EMBED_BASE = the product domain)
so the referrer is a legitimate embedding site. The iframe still points at
youtube.com/embed. Verified: video now plays. (The earlier base=youtube.com was
the Error 153 fix; this supersedes it - a normal domain referrer fixes 153 too.)
2026-06-09 15:36:24 -05:00
screentinker b88150c115
Merge pull request #64 from screentinker/docs/help-ai-blurb
docs(help): AI Content Design quick-start in Help (#41)
2026-06-09 13:58:57 -05:00
ScreenTinker 09f543fb8b docs(help): add AI Content Design quick-start to the in-app Help page (#41) 2026-06-09 13:58:53 -05:00
screentinker 4a64053d66
Merge pull request #63 from screentinker/docs/local-ai-setup
docs: local AI setup guide for the Content Designer (#41)
2026-06-09 13:57:06 -05:00
ScreenTinker 1a4397ad24 docs: local AI setup guide for the Content Designer (#41)
How to run the AI design feature fully local + free: Ollama (OpenAI-compatible
LLM) for text/layout and stable-diffusion.cpp (Vulkan) for images, plus the
SELF_HOSTED requirement for localhost endpoints, an OpenAI fallback, and GPU
troubleshooting (incl. the Blackwell CUDA-fails/Vulkan-works note). Linked from
the README integrations section.
2026-06-09 13:57:02 -05:00
screentinker c1aee36326
Merge pull request #62 from screentinker/fix/ai-separate-image-key
feat(ai): separate optional image API key (#41)
2026-06-09 13:47:52 -05:00
ScreenTinker dc6424a3cc feat(ai): separate optional image API key (#41)
Image generation reused the single (text-endpoint) API key, which breaks the
common 'local LLM with no key + OpenAI for images' setup. Add an optional
image_api_key (encrypted, write-only, never returned); generate-design uses it
for image calls and falls back to the main key when blank (all-OpenAI setups).
Local sd.cpp / ComfyUI still need no key. Schema column + migration.
2026-06-09 13:47:47 -05:00
screentinker c23e8ca289
Merge pull request #61 from screentinker/feat/ai-images-phase2
feat(ai): background + foreground images for signs (#41 Phase 2)
2026-06-09 13:40:19 -05:00
ScreenTinker 303c83e86a feat(ai): generate background + foreground images for signs (#41 Phase 2)
A prompt now produces a full sign: the LLM writes the design AND image prompts,
the server generates the images and composites them with the crisp text layer.

- lib/image-gen.js: text-to-image with 3 BYO/self-hostable backends, all behind
  the SSRF guard: 'sdcpp' (local stable-diffusion.cpp OpenAI-compatible server,
  exact small sizes that fit VRAM), 'openai' (cloud / OpenAI-compatible, snapped
  sizes), 'comfyui' (prompt/history/view API).
- ai.js: prompt asks for a background_prompt (preferred — full-bleed atmosphere)
  and an optional foreground image element; after the design is normalized, the
  bg + fg images are generated best-effort (a failed image never fails the sign)
  and returned as data URLs. New image_* settings (provider/base_url/model),
  image_provider whitelist, schema column + migration.
- designer.js: AI-images section in settings; generate applies the background
  image; publish bakes the background image into the HTML so it survives.
- server.js: raise JSON body limit to 12mb for embedded image data URLs.

Verified end-to-end on local Vulkan SDXL (RTX 5090): prompt -> bg+fg images on
the canvas -> publish creates a widget with the images embedded. 63/63.

Note: prod (not self-hosted) requires a PUBLIC image endpoint (e.g. OpenAI); the
SSRF guard blocks localhost there. Follow-up: upload generated images to the
content store and reference by URL to avoid multi-MB widget configs.
2026-06-09 13:40:14 -05:00
screentinker df4110d9ca
Merge pull request #60 from screentinker/fix/ai-deoverlap
fix(ai): de-overlap text + layer shapes behind text (#41)
2026-06-09 12:57:45 -05:00
ScreenTinker 734795f20b fix(ai): de-overlap generated text + layer shapes behind text (#41)
Models sometimes stacked text lines at the same y (unreadable) and emitted accent
shapes after text, so a band could hide the words.

- deoverlapTexts: push a line down only when it also overlaps horizontally
  (leaves side-by-side text alone), with conservative line-height clearance so
  real rendering doesn't re-overlap; shift the stack up if it ran past the bottom.
- Order shapes before text in the output so accent bands always render behind the
  words.

Verified: 0 text-on-text overlaps across multiple prompts (Playwright DOM check);
unit test asserts overlapping lines get separated + shapes precede text. 63/63.
2026-06-09 12:57:41 -05:00
screentinker 958f5683e4
Merge pull request #59 from screentinker/fix/ai-canvas-fit
fix(ai): keep generated designs inside the canvas (#41)
2026-06-09 12:51:27 -05:00
ScreenTinker 4cc8ccb67e fix(ai): keep generated designs inside the canvas (#41)
Text could run off the edge (long/large headlines, nowrap) and shapes placed at
the far edge (e.g. a bottom band at y=100) spilled over.

- Server-side fit pass on every generated element: shrink text fontSize so it
  fits the canvas width (chars*fontSize*0.075, tuned for bold/uppercase
  headlines) and height (incl. line-height), then nudge x/y within 4% margins;
  clamp shapes so x+width<=100 and y+height<=100. Deterministic - doesn't rely on
  the model getting layout right.
- Designer preview: vw -> cqw (+ container-type on the canvas) so text scales to
  the canvas, not the browser window. The preview was overstating size vs what
  actually publishes; now it matches. Published widget keeps vw (scales on the
  player).

Verified: Playwright DOM check shows zero elements overflowing the canvas after
generation; unit test asserts long text is shrunk + repositioned in-bounds. 62/62.
2026-06-09 12:51:23 -05:00
screentinker f7f78a7486
Merge pull request #58 from screentinker/feat/ai-model-dropdown
feat(ai): model dropdown + longer timeout (#41)
2026-06-09 12:36:34 -05:00
ScreenTinker 1420a0d2b7 feat(ai): model dropdown + longer generate timeout (#41)
- POST /api/ai/models lists the configured endpoint's models (OpenAI-compatible
  /models) so the settings modal can populate a 'Load models' dropdown instead of
  requiring users to type the model name. Combobox (datalist) so they can still
  type a custom one. Admin only; same SSRF guard; uses the posted or saved key.
- Bump generate-design timeout 120s -> 180s for slow local endpoints.
2026-06-09 12:36:29 -05:00
screentinker d117016f2d
Merge pull request #57 from screentinker/feat/ai-content-design
feat(ai): AI content design, BYO endpoint (#41 Phase 1)
2026-06-09 12:24:00 -05:00
ScreenTinker 0ba36949cf feat(ai): AI content design in the Designer, BYO endpoint (#41 Phase 1)
Competitor pressure (Mandoe 'AI Magic Create'): prompt -> signage. We answer it
in a way that's actually BETTER for signage and costs the operator nothing.

Key idea: don't generate raw images (AI garbles text - fatal for menus/promos).
The LLM returns a STRUCTURED design spec (headline, supporting text, accent
shapes, palette) that the existing Designer renders with real fonts - crisp and
fully editable. Reuses the whole Designer.

BYOK, fully under the customer's control: each workspace configures its own
OpenAI-COMPATIBLE endpoint + key - OpenAI cloud OR self-hosted (Ollama / LM Studio
/ llama.cpp). Operator bears zero AI cost/liability.
- server/lib/secretbox.js: AES-256-GCM for the key at rest (never returned).
- routes/ai.js: GET/PUT /api/ai/settings (admin; key write-only) + POST
  /generate-design (editor+). Output is strictly validated/normalized (cap count,
  clamp ranges, px->%, strip HTML, validate colors) - never trust the model.
  SSRF guard: hosted instances block private/internal targets; self-hosted (the
  whole point of local AI) may point at localhost/LAN.
- Designer: an 'AI generate' panel (prompt + Generate) + a settings modal.

Verified end-to-end against local Ollama (llama3.1:8b): prompt -> editable design
on the canvas. Unit tests cover normalization + the SSRF guard. Suite 61/61.

Phase 2 (next): AI background images (OpenAI images / AUTOMATIC1111).
2026-06-09 12:23:55 -05:00
screentinker bcdffd4f56
Merge pull request #56 from screentinker/fix/branding-fouc
fix(branding): no default-brand flash on load/switch (#38)
2026-06-09 11:43:46 -05:00
ScreenTinker 2de99a12e9 fix(branding): no ScreenTinker default flash on load/switch (#38)
The logo/title/theme/favicon are static 'ScreenTinker' in index.html, and
applyBranding() only overrode them AFTER an async /api/white-label fetch - that
network delay was the flash, on every load and on switch (which reloads).

Now applyBranding caches the resolved white-label per workspace (keyed by the
JWT's current_workspace_id), and a tiny same-origin brand-prime.js loads
render-blocking right after the logo - so it applies the cached colors/name/
title/favicon/custom-css BEFORE first paint. CSP-safe (external 'self' script,
not inline). applyBranding still runs to refresh + re-cache. First-ever visit to
an uncached branded workspace still shows the default once; every load after is
flash-free.
2026-06-09 11:43:42 -05:00
screentinker 97c52408de
Merge pull request #55 from screentinker/fix/content-thumbnail-auth
fix(content): thumbnails for not-yet-assigned uploads (#39)
2026-06-09 11:19:01 -05:00
ScreenTinker 6760f61fb8 fix(content): show thumbnails for not-yet-assigned content (#39)
After uploading, content thumbnails were blank until the item was added to a
playlist/widget. The public /api/content/:id/thumbnail (and /file) endpoints are
reference-gated (an anonymous player with a UUID must not pull arbitrary tenants'
media), and a plain <img> can't send a Bearer token - so a just-uploaded item 403'd.

- Backend: add an authenticated bypass - a logged-in user who can access the
  content's workspace (verified from the Bearer token) may view its file/thumbnail
  even when unreferenced. Anonymous players still hit the reference gate.
- Frontend: the content library lazy-fetches thumbnails/previews WITH the token
  and swaps in an object URL (IntersectionObserver keeps it under the rate limit;
  the URL is revoked after load).

Verified: unreferenced thumbnail now 200 with a bearer token, still 403 anonymous.
2026-06-09 11:18:56 -05:00
screentinker 61279e9bea
Merge pull request #54 from screentinker/fix/upload-multiple-hint
ui(content): advertise multi-file upload in drop-zone text (#39)
2026-06-09 10:50:56 -05:00
ScreenTinker 020f0bfea7 ui(content): advertise multi-file upload in the drop-zone text (#39)
The upload input already has 'multiple' and the click handler shares handleFiles()
with drag-drop, so picking multiple files (shift/ctrl-click) already works - it just
wasn't discoverable ('click to upload' read as single-file). Reword to 'click to
select one or more'.
2026-06-09 10:50:51 -05:00
screentinker 3f429aec85
Merge pull request #53 from screentinker/fix/template-zone-duplication
fix(layouts): atomic zone save — stop template zone duplication
2026-06-09 10:16:06 -05:00
ScreenTinker cb21b8e34a fix(layouts): atomic zone save (stop template zone duplication)
Saving a layout grew its zone count on every server restart. Root cause: the
editor saved zones with a per-zone delete-then-POST loop, and POST /zones minted
a NEW uuid for every zone - so each save replaced the seeded ids (z-sh-1, ...)
with fresh uuids. schema.sql re-seeds template zones via INSERT OR IGNORE on every
boot, so the next restart re-added the now-missing canonical zone alongside the
renamed copy -> a 2-zone template became 4, 6, ... (worse for self-hosters who
rebuild often).

Fix:
- PUT /api/layouts/:id now accepts a zones[] and replaces them atomically in one
  transaction, REUSING each zone's id when supplied. The editor sends the full
  set in a single call, so the layout ends up with exactly those zones and ids
  stay stable (also fixes fit_mode not persisting, and stops device->zone
  assignments being orphaned by id churn).
- One-time dedupe migration removes positional-duplicate template zones, keeping
  the canonical 'z-...' seeded id so the re-seed stays an idempotent no-op.

Verified: 2 atomic saves keep count + ids stable with fit updated; dedupe restores
a polluted 4-zone split template to its 2 canonical zones. Suite 56/56.
2026-06-09 10:16:01 -05:00
screentinker e2460855d9
Merge pull request #52 from screentinker/fix/migrate-count-addcolumn
fix(db): boot log counts only ADD COLUMN (#37 follow-up)
2026-06-09 10:02:43 -05:00
ScreenTinker bae70e9154 fix(db): count only ADD COLUMN as new migrations in boot log (#37 follow-up)
The boot summary counted any non-throwing statement, so UPDATE/index migrations
(which always succeed) made a healthy DB report 'applied N new column migration(s)'
every boot. Count only a successful ALTER ... ADD COLUMN (genuinely new), so the
line appears only when a column was actually added.
2026-06-09 10:02:38 -05:00
screentinker 7ef3e2eb93
Merge pull request #51 from screentinker/fix/migration-schema-verify
fix(db): observable migrations + fail-fast schema verification (#37)
2026-06-09 09:31:57 -05:00
ScreenTinker 7ab19adcea fix(db): observable migrations + fail-fast schema verification (#37)
Self-hosters rebuilding could end up schema-behind-code, failing only at runtime
(a missing users.must_change_password locked out all logins). Two root causes:

1. The migration loop swallowed EVERY error (catch {}), so a real ALTER failure
   was indistinguishable from the benign 'duplicate column' on an already-migrated
   DB. Now only 'duplicate column'/'already exists' is treated as a no-op; any
   other error is logged loudly, and a one-line summary reports how many new
   column migrations actually applied this boot.

2. Nothing verified the schema after migrations. Added lib/schema-check.js:
   verifyAndRepairSchema() checks the tables + columns the request path REQUIRES,
   idempotently repairs missing repairable columns (logging each), and if anything
   required is STILL missing, prints a loud FATAL block and exits - failing fast at
   boot instead of at the first authed request.

Note: the reported 'audit_log missing' was a misdiagnosis - the code uses
activity_log (0 refs to audit_log), created by schema.sql on every boot.

Tests: healthy (no-op), auto-repair of must_change_password, missing-table report.
2026-06-09 09:31:52 -05:00
screentinker 9deccf0a2f
Merge pull request #50 from screentinker/feat/admin-delete-org-workspace
feat(admin): Delete Organization + Workspace with cascade (#36)
2026-06-09 09:22:25 -05:00
ScreenTinker 0d14db97a6 feat(admin): Delete Organization + Workspace with cascade (#36)
Platform admins can now cleanly remove a customer org (account ends) or a stray
workspace from the UI, instead of raw SQL that risks orphaning resources.

The tenant cascade isn't pure DB CASCADE - workspace-scoped tables (devices,
content, playlists, ...) are NO ACTION and must be purged before the workspace.
Extracted that logic out of deleteUserCascade into shared deleteWorkspaceCascade /
deleteOrgCascade helpers (one tested implementation; deleteUserCascade now reuses
the purgeWorkspaces extraction).

Backend (platform-admin only): GET /api/admin/orgs (list + owner + counts +
workspaces), DELETE /api/admin/orgs/:id, DELETE /api/admin/workspaces/:id.
UI: an Organizations section in Admin listing every org/workspace with a
type-the-name confirmation before the irreversible delete.
Tests: org/workspace cascade (real FKs) + endpoint gating/404. Suite 53/53.
2026-06-09 09:22:21 -05:00
screentinker 36d1578794
Merge pull request #49 from screentinker/feat/admin-create-org
feat(admin): Create Organization for platform admins (#35)
2026-06-09 09:10:20 -05:00
ScreenTinker ae595a208d feat(admin): Create Organization for platform admins (#35)
MSPs onboarding customers as separate orgs had no way to create one with
AUTO_CREATE_ORG_ON_SIGNUP=false (the only path was signup auto-org). Add a
platform-admin 'Create organization' action.

POST /api/admin/orgs (requirePlatformAdmin) creates the org + its first 'Default'
workspace. organizations.owner_user_id is NOT NULL, so an org can't be ownerless;
the creating admin becomes org_owner + workspace_admin (mirrors the signup
bootstrap in routes/auth.js) - which also surfaces the org in their switcher.
Customer users are then added via the existing Add User / manage-memberships flow.

UI: 'Create organization' button + single-field modal in the Admin area (gated).
Tests: create (201 + memberships + audit), empty-name 400, non-admin/operator 403.
2026-06-09 09:10:15 -05:00
screentinker 69b46647c5
Merge pull request #48 from screentinker/feat/zone-fit-mode
feat(layouts): per-zone fit mode (fix cropped multi-zone video)
2026-06-09 08:55:20 -05:00
ScreenTinker 8fd971405e feat(layouts): per-zone fit mode + default to 'contain'
Multi-zone videos/images were cropped: every template zone inherited fit_mode
'cover' (fill+crop) and the layout editor had no control to change it, so a
landscape video in a tall split zone showed only a center strip. The player
already honors fit_mode (web object-fit, Android scaleType) - the gap was the UI
and the default. Add a per-zone Fit selector (Contain/Cover/Stretch) to the layout
editor, and make 'contain' (show the whole frame) the default for new zones, the
schema column, and the save fallbacks. Existing built-in templates are migrated
separately.
2026-06-09 08:55:15 -05:00
screentinker 7af9f7a057
Merge pull request #47 from screentinker/fix/player-coldstart-layout
fix(player-web): no fullscreen flash on true cold start (unknown layout)
2026-06-09 08:31:03 -05:00
ScreenTinker 397aedf2d8 fix(player-web): don't optimistic-render fullscreen when layout is unknown
Follow-up to the layout cache. On a cold start with a cached playlist but no cached
layout yet (first run after shipping, or cleared cache), the player still rendered
fullscreen and flashed before the payload arrived. Now gate the optimistic cached
render on the layout being KNOWN (cache key present — null=fullscreen vs object=
zoned, both fine); if unknown, wait ~1s for the payload to drive the first render.
Eliminates the fullscreen flash on the very first pass too.
2026-06-09 08:30:58 -05:00
screentinker 8de15465ad
Merge pull request #46 from screentinker/fix/player-cache-layout
fix(player-web): cache layout — cold start renders zones on first pass
2026-06-09 08:27:46 -05:00
ScreenTinker 00964e90a8 fix(player-web): cache layout so cold start renders zones on first pass
The player cached only the playlist, not the layout. On cold start it restored the
playlist and rendered immediately with layout=null -> fullscreen, then re-rendered
into zones once the server payload arrived (the 'fullscreen first, then split'
flash). Cache the layout alongside the playlist and restore it before the first
render; cleared on reset.
2026-06-09 08:27:41 -05:00
screentinker ccee032740
Merge pull request #45 from screentinker/fix/zone-widget-content-type
fix(player-web): render widgets in content zones (black-zone bug)
2026-06-09 08:22:10 -05:00
ScreenTinker 4fe8e87416 fix(player-web): render widgets in any zone, not just zone_type=widget
A widget (e.g. directory board) assigned to a 'content' zone rendered as a black
zone: showZoneItem gated the widget branch on zone.zone_type==='widget', so the
widget was skipped and (mime_type null) nothing else matched either. Key off the
assignment's widget_id instead - matching the Android ZoneManager, which is why
the same layout worked on the APK but not the web player.
2026-06-09 08:22:05 -05:00
screentinker 67d2eae2cf
Merge pull request #44 from screentinker/fix/widget-render-nostore
fix(widgets): no-store on widget/kiosk render
2026-06-08 23:46:46 -05:00