Commit graph

565 commits

Author SHA1 Message Date
screentinker 5c6d508032
feat(content): multi-file upload (#222)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Uploading N files fired N sequential XHRs (one POST per file). Select-many now
goes up in a single request.

- Server POST /api/content: upload.array-style `files` field (up to 20) via
  upload.fields, looping ingestUploadedFile per file. Keeps the legacy single
  `file` field so older clients / API callers are unaffected. Response shape is
  backward-compatible: a single file returns the content object (what every
  existing caller reads), a batch returns the array.
- api.uploadContent: accepts a File, FileList, or array; appends all under
  `files`; aggregate upload progress; resolves to object (single) or array
  (batch).
- content-library handleFiles: one batched request with aggregate progress and
  a "N files uploaded" toast instead of a per-file loop.
- en/es i18n for the count-based progress/toast strings.

checkStorageLimit is left as-is — it's a coarse pre-gate (blocks only when
already at/over the limit), same as before; per-file aggregate sizing was a
listed "consideration", not required, and is out of scope here.

Test: content-multi-upload.test.js drives the real router+multer over HTTP —
3-file batch creates 3 rows and returns an array, legacy single `file` returns
an object, single `files` returns an object, empty -> 400. Suite 545/545.

Closes #212

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:38:37 -05:00
screentinker 792b105035
feat(content): server-side search, type filter, and sort (#221)
Content discovery was client-side only, scoped to the items already rendered
on the current page — searching "logo" on page 1 couldn't find logos on page
2 or in another folder.

Server (GET /api/content):
- ?q= text search on filename (LIKE, workspace-wide — a search ignores the
  open folder so nothing is missed). LIKE metacharacters are escaped so a
  filename with % or _ matches literally.
- ?type=video|image|youtube|web — youtube (video/youtube) and web (other
  remote_url) are split from plain uploaded video/image so the four UI buckets
  map cleanly.
- ?sort=date_desc|date_asc|name|size — whitelisted (never interpolates user
  input into ORDER BY); default keeps the legacy newest-first ordering.

Frontend (content-library):
- Type filter + sort dropdowns; search debounced (300ms) and now hits the
  server instead of filtering the DOM.
- Result count shown while a search/type filter is active.
- en/es i18n.

api.getContent gains an opts arg ({q,type,sort}); folder_id is omitted while
searching to match the server's workspace-wide behaviour.

Test: content-search-filter-sort.test.js mounts the real router and covers
substring match, LIKE-escape (literal %), the type buckets, name/size sort,
the ORDER BY injection guard, and combined filters. Suite 541/541.

Closes #214

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:38:22 -05:00
screentinker ad03a5ec0a
feat(content): unstable-connection mode — cap YouTube at 720p for weak WiFi (#220)
On Android TV with weak/unstable WiFi, YouTube embeds auto-select 1080p+
and buffer/stall. Add a per-item "Unstable connection" flag that biases the
YouTube embed toward a 720p ceiling.

- DB: content.unstable_connection INTEGER NOT NULL DEFAULT 0 (non-destructive,
  existing rows unchanged).
- buildSnapshotItems: denormalize the flag into published_snapshot so it
  reaches the player (that query enumerates columns, so it had to be added
  explicitly — covered by a new test). preview-payload reuses the same query.
- content.js PUT: accept + coerce unstable_connection to 0/1.
- Player: playerVars.vq='hd720' + best-effort setPlaybackQuality('hd720') in
  onReady when the flag is set. Both are hints YouTube may still override, but
  together they bias the initial selection down to 720p.
- Edit modal: YouTube-only checkbox + hint; en/es i18n.

Scoped to the core ask; the issue's optional "Shorts get inverse hd1080"
refinement is intentionally left out (dubious for signage, and forcing higher
quality on a weak link is the opposite of the goal).

Closes #217

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:38:18 -05:00
screentinker 9d6c3c79b0
fix(web-player): YouTube ENDED safety net for Shorts + flaky Android TV (#219)
YouTube Shorts never fire the ENDED state via the IFrame API, and some
Android TV WebViews drop ENDED even for regular videos. The player advanced
solely on onStateChange ENDED, so a missing event stalled the playlist
indefinitely.

Arm a duration-based fallback timer in onReady (getDuration + 3s slack) that
calls nextItem() if ENDED never arrives. It is cleared on a real ENDED, on
onError, when a newer player is created, and in teardownCurrentMedia so a
stale timer can't force a spurious advance after rotation. Skipped when
looping (single-item playlists) and when duration is 0 (live streams).

The Tizen player is not affected: it embeds YouTube as a plain iframe and
already advances multi-item playlists on a duration timer rather than the
YT JS API, so it never waits for ENDED.

Closes #215
Refs #184

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:34:25 -05:00
screentinker 11c2890433
fix(android): visible D-pad focus stroke on setup buttons (#218)
The setup screen buttons used a bare <ripple> which only reacts to
state_pressed (touch). D-pad navigation triggers state_focused, which the
ripple ignored, leaving the focused button nearly invisible against the
dark background from TV viewing distance.

Wrap the button shape in a <selector> (kept inside the ripple so touch
still gets the press ripple) that adds a bright 3dp white stroke on
state_focused.

Closes #209

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:34:21 -05:00
Fabian Mendoza e7483dfc24
feat(ui): show server URL in Add Display modal + GitHub Releases link on /download/apk (#210)
- Add Device modal now shows the server URL and full Smart TV player URL
- Smart TV note changed from bare /player to full URL (dynamic via JS)
- /download/apk error page now includes a download link to GitHub Releases
- i18n keys added in en + es, old smart_tv_note removed
2026-07-23 10:25:00 -05:00
Fabian Mendoza 9e0048eec2
fix(content): respect current folder when uploading files (#211)
Previously the dashboard upload always sent files to root (folder_id=NULL)
because the upload flow never read or forwarded the current folder context.
The agency upload already handled this correctly — this applies the same pattern.

Changes:
- api.js: uploadContent() accepts optional folderId, appends to FormData
- content-library.js: handleFiles() passes state.currentFolderId
- content.js: POST / reads folder_id from multipart body
2026-07-23 10:24:57 -05:00
ScreenTinker 79ab849641 chore(release): v1.9.12
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-07-22 21:09:09 -05:00
ScreenTinker b938fce368 feat(auth,tizen): TOTP 2FA UI, email verification on signup, Tizen SSSP install
Three features from this session, full server suite green (535/535).

TOTP 2FA (#100) — backend shipped without a UI; add it:
- Login: mfa_required -> 6-digit challenge (recovery codes accepted) -> /totp/verify.
- Settings > Account: enable (QR + confirm -> recovery codes once), regenerate,
  disable; SSO accounts see "managed by your identity provider".
- /totp/setup returns a server-rendered qr_data_url (bundled qrcode dep). keyuri
  folds the request Host into the issuer so multi-instance accounts are
  distinguishable in the authenticator app.

Email verification on signup — hosted HARD-block / self-host SOFT-nudge:
- email_verified column; existing users asked on first login (SSO + platform
  admins grandfathered); single-use 24h tokens (SHA-256 hashed).
- Gate engages only when email is configured (never locks out a no-mail instance).
  GET /verify-email + POST /resend-verification (generic, no account enumeration).
- Client: "confirm your email" flow + resend, verified/error toasts, self-host
  banner; onAuthSuccess refuses a tokenless response (defensive).

Tizen SSSP URL-Launcher install — Fusion-style one-URL native install:
- Server hosts /tizen/sssp_config.xml (dynamic <size>, always matches the served
  .wgt) + /tizen/ScreenTinker.wgt + a human landing. lib/wgt-cache.js resolves the
  signed .wgt (/data mount wins, mirroring the APK).
- build-wgt.sh also emits a static sssp_config.xml for CDN hosting.
- Retail panels require a Samsung Partner cert; dev-mode is SDB self-signed only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:08:50 -05:00
ScreenTinker 363f8de809 fix(web-player): hoist renderSeq to top-level state — fixes cold-start TDZ crash
The cold-start cached-playlist restore runs at top-level during initial
script execution: it calls startPlaybackAt(0) -> playCurrentItem ->
renderContent, whose first statement is `renderSeq++`. But renderSeq was
declared with `let` next to the buffered-video code far below, so it was
still in the temporal dead zone on that early path:

  ReferenceError: can't access lexical declaration 'renderSeq' before
  initialization  (renderContent -> playCurrentItem -> startPlaybackAt)

Result: any paired device with a cached playlist + known layout threw on
cold load and rendered nothing. Regression from the warm-play/buffered
render work, which made renderContent touch renderSeq at its very top.

Fix: declare `let renderSeq = 0` with the other top-level player state so
it is initialized before the restore path can call renderContent. No
behavior change to the buffered-render logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:34:35 -05:00
ScreenTinker 07419fee1f feat(players): proof-of-play on Android+Tizen; close Tizen parity gaps
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Android and Tizen never emitted device:play-event, so Reports showed Total
Plays / Hours / proof-of-play as all zero for those devices (only the web
player logged plays). Both now emit play_start on show and play_end on
advance/teardown, mirroring the web player's contract (leader-gated for walls,
widget-id fallback so durations close). Server side unchanged — play_logs and
the reports queries were already waiting for the events.

Tizen parity with the web player (from the parity audit):
- audio: landscape <video> honors item.muted (warm-muted for autoplay, then
  applies the real state) instead of force-muting; wall followers stay silent
- device:mute-changed: real-time per-item mute of the on-screen video
- device:remote-key: D-pad/volume/mute/home, BACK=info overlay, POWER=screen-off
- device:remote-touch: normalized-coordinate touch injection
- buffered widget swap: reveal the new iframe on load then clear (no black flash)
- diagnostic info overlay toggled by the dashboard BACK key

Still muted on Tizen: the portrait AVPlay video path and transition-composited
video (would need webapis.avplay volume APIs / renderVideoBuffered work).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:22:18 -05:00
ScreenTinker 2d4af97f67 docs: fix double-escaped < in API reference, add README hero image
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
- openapi.yaml: use the numeric entity &#60; so Redoc renders
  'read < write < full' in the scope-ladder nav + section header,
  instead of the double-escaped 'read &lt; write &lt; full'
- README: add a centered dashboard hero image + quick-links row
  (Live demo / API reference / Self-hosting guide / Discord) at the top,
  and refresh the Support section

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:51:55 -05:00
screentinker 8c0bf77428
fix(web-player): reconcile advanceTimer on group/wall mode transitions (#200) (#208)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Reconcile advanceTimer on mode enter/exit via reconcileAdvanceTimerForMode in applyWallMode/applyGroupSync — fixes the group-entry zombie timer and the solo-exit frozen image. Closes #200.
2026-07-21 09:00:42 -05:00
screentinker 3efae1d2d6
feat(designer): edit designer-made widgets in the designer (+ legacy reconstruction) (#207)
Designs round-trip for visual editing: store the design source in the widget config, reroute Edit to the designer, reconstruct legacy HTML-only designs, PUT the original in place.
2026-07-21 09:00:36 -05:00
screentinker 397c4e1aec
fix(designer): let weather elements switch units to metric (#206)
Some checks failed
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Shaders / Compile transition shaders (real WebGL) (push) Has been cancelled
Adds the missing Imperial/Metric units selector to the designer's weather element properties.
2026-07-20 18:22:42 -05:00
ScreenTinker 6b5b401f7c chore(release): v1.9.11 2026-07-20 16:59:56 -05:00
screentinker ba00dd2811
fix(transition-engine): Android supersede wedge/leak + web/Tizen stale-video guard (#205)
Pre-release review follow-up to #204: fixes the Android superseded-wipe playlist wedge + GL leak, and adds the stale-item guard to web/Tizen renderVideoBuffered.
2026-07-20 16:58:28 -05:00
screentinker 96b71a0d56
feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated.
2026-07-20 16:45:32 -05:00
ScreenTinker af89eaa75b chore(release): v1.9.10
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
2026-07-17 20:24:39 -05:00
screentinker 335681b907
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.
2026-07-17 20:17:21 -05:00
screentinker bb6c7597da
fix(web-player): buffered widget swap + schedule-aware solo-board hold (directory-board black flicker) (#202)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* fix(web-player): buffered widget swap + solo-board hold to end directory-board black flicker

A fullscreen widget (e.g. a solo directory board) re-rendered on the advance timer:
renderContent tore the container down to black (innerHTML='') BEFORE the replacement
iframe finished loading, and a single/only-active widget re-advanced to itself every
duration_sec — so the board cycled black every few seconds. That reload was ALSO the
only thing refreshing the board's static, server-rendered data, so simply holding it
in place would freeze the data.

- Buffered swap: build the new widget iframe hidden OVER the current content and reveal
  it on 'load', then tear down the outgoing content — no black frame on any widget
  transition. On a load timeout, keep the last-good board and discard the dead hidden
  frame via a shared cleanup path (don't reveal a blank frame); a transient server blip
  self-heals on the next refresh.
- Solo/held widget (nextActiveIndex === currentIndex): hold in place and refresh its
  DATA on a decoupled interval (WIDGET_SOLO_REFRESH_MS = 60s) via the buffered swap,
  instead of re-querying the DB + re-rendering full HTML every duration_sec, fleet-wide.

Scoped to non-wall fullscreen widgets; wall+widget keeps the legacy path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web-player): route held directory-board refresh through nextItem (schedule-aware)

Follow-up to the buffered widget swap: the solo/held board refreshed via a bespoke
self-rescheduling loop that never re-evaluated the schedule — so a board could outlive
its daypart, and a newly-active sibling item was never picked up (the player stuck on
the board). Delete the duplicate loop entirely and advance via nextItem in both the
held (WIDGET_SOLO_REFRESH_MS cadence) and rotating (duration) cases: nextItem
re-evaluates the schedule every cycle and re-renders the held board through the buffered
swap (still no flash), and drops the duplicate code path that caused the bug.

Verified: the timer-lifecycle harness (6 scenarios / 68 assertions) still passes,
including widget->video transition and the leak/timer-count checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:42:12 -05:00
screentinker 5c1cb4b992
fix(server): floor duration_sec to prevent widget zero-duration player loop (#199)
A duration_sec=0 assignment (especially a widget) made the player schedule a 0ms
auto-advance, self-looping and black-screening the TV. #198 fixed the Android
client; this hardens the source so a 0 can't be stored or served in the first
place. assignments.js accepted an explicit 0 on the POST/PUT/copy write paths —
the `= 10` destructure default only covers an ABSENT field, not an explicit 0.

- Add normalizeDuration() and apply it on all assignment write paths so any
  missing/invalid/<1 duration is floored to the 10s default.
- Add an idempotent migration repairing existing playlist_items rows with
  duration_sec IS NULL OR < 1 (fixes the live widget on existing DBs).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:41:52 -05:00
screentinker af286826dc
fix(android): stop zero-duration widget self-loop pegging the main thread (#198)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
A solo fullscreen widget (or image) with duration_sec=0 hit an unclamped
scheduleAdvance(item.durationSec * 1000L) in PlaylistController.playCurrentItem,
scheduling a 0ms auto-advance. For a single-item playlist next() re-selects the
same item, so it re-played every looper tick (~20x/sec) — black-screening the TV
and locking the UI (couldn't even reach home). Triggered when a schedule collapses
the playlist to a single always-on duration-0 widget.

Use slotMs() (the max(1, duration||10) contract shared with the web/Tizen players)
so a zero/negative duration floors to 10s. Also floor scheduleAdvance() itself to
MIN_ADVANCE_MS (500ms) as a backstop so no future path can busy-loop the main thread.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:01:19 -05:00
screentinker 059ee1744e
fix(widgets): directory board scroll stutter — seamless loop gap mismatch (#197)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
The vertical auto-scroll is a CSS keyframe that translates the track by
cycleH = baseH + GAP_PX and loops linear infinite. GAP_PX was 100 but the actual
.gap element between the content and its seamless clone is 120px, so every cycle
the reset landed 20px off — a visible jump/stutter once per loop.

Set GAP_PX = 120 to match the .gap CSS, and drive each gap element's height from
GAP_PX inline so the scroll math and the rendered gap can never drift again.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:11:21 -05:00
screentinker 5f5ec88eb0
fix(widgets): put the CORP: cross-origin header on the route that actually serves content (#196)
Follow-up to #195. The CORP fix there landed on routes/content.js `/:id/file`, but that
handler is SHADOWED: server.js registers a public `app.get('/api/content/:id/file')`
(and `/thumbnail`) BEFORE the auth-gated content router, and that public route (gated by
playlist/widget reference) is what actually serves widget logo/background images. So the
header never changed on the wire — origin still returned CORP: same-origin and the player's
sandboxed (opaque-origin) widget iframe kept getting NS_ERROR_DOM_CORP_FAILED / 0 bytes.

Set Access-Control-Allow-Origin: * + Cross-Origin-Resource-Policy: cross-origin on the real
public routes in server.js: /file, /thumbnail (local), and the remote-thumbnail proxy.
Revert the now-dead content.js edit so the fix lives only where the bytes are served.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:02:31 -05:00
screentinker 178af029a4
Directory board: JSON/CSV import + logo-replaces-title + fix images on player (#195)
* feat(widgets): bulk import for the directory board (JSON / CSV / TSV / text)

Adds an "Import from JSON / CSV" button to the directory-board editor. Paste JSON
(the { company, tenantsByFloor, advertisements, backgroundImages } shape plus
categories[]/floors[]/flat-array/bare-floor-map variants), a CSV/TSV/pipe/semicolon
table (with or without a header — vacant/yes/1 => available, quoted fields), or a
sectioned "room name" text list, and it auto-fills title, footer, floors->categories,
rooms/names/details/availability, and background-image URLs. "Replace / append" toggle.

Tolerant key matching (room/suite/unit/id, name/tenant/company, details/subtitle, …);
warns on things it can't use (bare-filename background images, headerless columns).
parseDirectoryImport is pure and was unit-tested in node across every format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(widgets): directory board — logo replaces title, and images load on the player

Two on-screen bugs on the directory board:

1. A logo did not remove the title text — both rendered, stacking the wordmark over
   the name. renderDirectoryBoard (and the directory-search header) now gate the title
   h1 behind !logoSrc, so a logo replaces the title. New render test guards it.

2. Logo + background images did not show on the player (NS_ERROR_DOM_CORP_FAILED,
   0 bytes). The player embeds widgets in a sandbox="allow-scripts" (opaque-origin)
   iframe, so /api/content image requests are cross-origin, and the helmet default
   Cross-Origin-Resource-Policy: same-origin blocks them. Set CORP: cross-origin (+
   ACAO:*) on the content file + thumbnail routes, matching the existing /uploads/content
   static route. Content already serves publicly, so no new exposure. Verified in a real
   sandboxed iframe: same-origin blocks, cross-origin loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:53:34 -05:00
screentinker 61c1246b5b
fix(ui): make modals scroll instead of overflowing the viewport (#194)
The base .modal had no max-height/overflow on desktop — only the mobile media
query capped it — so a tall modal (e.g. the directory-board widget editor with
many tenant entries) grew past the screen with no scroll, stranding the lower
entries and the Save button off-screen ("unusable").

Cap .modal to 90vh and lay it out as a flex column so .modal-body becomes the
scroll region (flex + min-height:0 + overflow-y:auto) while the header and footer
(Cancel / Save) stay pinned and always reachable. Moved the cap onto the base
rule and dropped the now-redundant overflow from the mobile override.

Shared across all modals (all use the header/body/footer structure); short modals
are unchanged since max-height is a ceiling, not a fixed height. Verified in real
Chrome at a 700px viewport: modal capped to 630px, body scrollable (overflow=auto),
Save footer on-screen. CSS-only, no JS.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:12:52 -05:00
screentinker 76f072bf4b
fix(tizen): decode-gated image double-buffer to kill the black flash between stills [#187] (#193)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
The Tizen .wgt player black-flashed between IMAGE items on slow decode HW
(Samsung OM55B / SSSP). Root cause: playCurrent() called clearStage() before
renderImage() set img.src, so the stage was empty (black) until the new image
decoded. Images had no decode-gated double-buffer — video gained one in #167
(938a43a), which made the always-present image flash conspicuous by contrast.
renderImage has been byte-identical since the first Tizen commit, so there was
nothing to revert; the buffer had to be ADDED, mirroring the video path.

- preloadImage()/_takePreloadImage()/_releasePreloadImage(): one-ahead,
  image-only decode-gated buffer modeled on preloadVideo()/_takePreload().
  Detached <img>, src set, warmed via HTMLImageElement.decode() (feature-
  detected — onload/complete fallback for Tizen 5.0 / SSSP6). Warmed when the
  current image begins its dwell and from the group-sync boundary tick.
- renderImage now SWAPS: take the pre-decoded <img> (or decode a fresh one) and
  only THEN clearStage()+append, in one synchronous block — the compositor
  never sees an empty stage. Never clear-then-load on the image path.
- Scoped to images only: playCurrent() skips the up-front clearStage() solely
  for image targets (same branch order as the dispatch); video/youtube/widget
  keep their pre-dispatch clear untouched. onerror and decode() rejection route
  to skipSoon(); stale-index guard blocks mounting a stale decode over the
  current item after next()/gotoIndex/load(); one-ahead with stale release on
  index move, load(), stop(), and group-sync exit. #A1 single-item heal intact.

Tests:
- server/test/tizen-image-blackflash.test.js (new, 4/4): loads the real
  player.js in a vm context with a test-controllable decode() Promise and proves
  the invariant — across image->image the #stage is NEVER without a mounted
  <img> (old element held until the new image's decode()/onload resolves, then
  swap). Covers decode() supported, decode() absent (onload fallback), and
  broken-image (decode-reject / onerror) -> skipSoon. Proven to VIOLATE on the
  old clear-then-load ordering and HOLD on the fix.
- server/test/pip-overlay.test.js: the decode-gate makes image mount async,
  which broke its older shim (no decode()/onload/complete). Teach the shim
  element complete/naturalWidth so renderImage takes its synchronous
  complete-fallback branch and mounts. Test-only.

Full server suite 486/486. node -c clean. Headless proves the DOM ordering
invariant (the flash's precondition); final black-frame sign-off needs a real
OM55B panel (manual steps in the test-file header).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:29:39 -05:00
ScreenTinker 79cf4cac2b chore(release): v1.9.9
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-07-15 16:06:18 -05:00
screentinker ba0663edc1
fix(pairing): close deferred-offline reclaim race + idempotent same-code adopt (#192)
A fresh unclaimed player that reconnects (same fingerprint) INSIDE the server's
~5s deferred-offline grace hit a false 'active on another connection' reclaim
reject, then collided on UNIQUE(devices.pairing_code) on the fall-through INSERT
and wedged unclaimed with no content. Real trial customer (web player) hit it.

server/ws/deviceSocket.js:
- Fix A (guard): gate the liveConn reclaim reject on !inDeferredOffline
  (pendingOfflines.has(id)). A device mid-deferred-offline is a zombie, not live,
  so a same-fingerprint reconnect is a legit reconnect, not a hijack. A genuinely
  live socket (never disconnected -> no pending-offline) still rejects a cloned
  fingerprint -> anti-hijack boundary preserved (documented).
- Fix B (idempotency): when the unclaimed old row holds the SAME pairing_code the
  reconnecting player presents, ADOPT/refresh it (mirror the claimed-reclaim path,
  but no device:paired) instead of INSERT-colliding. Differing-code case unchanged.
- deferOffline is NOT shrunk (it exists to prevent transient-blip flapping).

server/player/index.html:
- The cold-boot flap source: an unfiltered pageshow handler ran verifyLivenessSoon()
  on every load, opening+registering a socket early, which the boot connect() then
  tore down and rebuilt (connect->register->disconnect->reconnect). Guard it with
  ev.persisted (mirror the pagehide guard) so only real bfcache restores trigger it.

server/test/pairing-race.test.js:
- Forces the race against the real socket server (log-gated reconnect inside the
  deferred-offline window), asserts no false reject / no UNIQUE collision / single
  claimable row; + a hijack case asserting a cloned fingerprint on a genuinely-live
  display is still rejected. Web- and android-shaped fingerprints. Fails 2/4 on
  pre-fix code, 4/4 with the fix.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:05:50 -05:00
ScreenTinker 87d5c5e1d7 chore(release): v1.9.8 2026-07-15 10:06:11 -05:00
screentinker 77322c631a
fix(android): show soft keyboard for PIN/URL dialogs over immersive fullscreen (#191)
The hidden-settings PIN box (2x back) and the change-server URL dialog couldn't be
typed into on kiosk devices: a plain AlertDialog shown over the player's
SYSTEM_UI_FLAG_IMMERSIVE_STICKY activity never gains window focus, so the soft
keyboard doesn't attach and the EditText gets no cursor. On a panel/Fire TV with
no hardware keyboard that means the PIN can't be entered at all. Pre-existing
(showPinDialog unchanged since it was added; immersive flags since the initial
release) — surfaces on tier-0 kiosk devices where immersive is active.

Fix: shared showImeDialog() applies the standard immersive-dialog IME workaround —
mark the dialog NOT_FOCUSABLE before show() (so it doesn't steal focus and reset
the activity's immersive flags), mirror the immersive systemUiVisibility onto the
dialog, then clear NOT_FOCUSABLE after show() so the IME can attach, force
SOFT_INPUT_STATE_ALWAYS_VISIBLE, and requestFocus the field. Routed both the PIN
and change-server dialogs through it.

Compiles (:app:compileDebugKotlin). IME behavior needs a real tier-0 device to
confirm (can't be exercised headless).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:00:26 -05:00
screentinker fa31eb9cc3
fix(android): reset stuck download backoff on content change + network reconnect (#170) (#190)
Symptom 1's 'stuck on first load, fixed by toggling the playlist' is stale
download backoff. On a fresh device the first downloads fail while the link is
settling; DownloadCoordinator escalates an exponential backoff (15s..5min cap),
and ensure() then SKIPS those items. The 60s playlist refresh re-fires
onPlaylistUpdate but ensure() still skips them, and backoff was only ever cleared
by forget() (content-delete) — never by a re-assignment. So the item stays stuck
until the 5-min window happens to lapse; toggling the playlist is just a manual
way to wait it out.

Fix (storm-safe — neither reset fires on the routine same-playlist 60s refresh):
1. DownloadCoordinator.resetBackoff(id) / resetAllBackoff() — clear attempts +
   nextAttemptAt but KEEP inFlight (single-flight preserved, no duplicate .part).
2. onPlaylistUpdate resets backoff for each item ONLY when the content-id
   signature changed (first load, reassignment, toggle-back), then ensures — so a
   genuine (re)assignment retries immediately. Same-signature 60s refresh -> no
   reset -> the retry-storm guard stays intact.
3. Network onAvailable (was onLost-only) -> resetAllBackoff() + requestPlaylistRefresh,
   so content that failed while the link was settling retries the moment real
   connectivity arrives.

Tests: DownloadCoordinatorTest — resetBackoff/resetAllBackoff re-attempt a
backed-off item before the clock advances; existing backoff/single-flight tests
unchanged. :app:testDebugUnitTest green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:07:43 -05:00
screentinker bc9e72ec0b
fix(content): render YouTube Shorts in 9:16 instead of a landscape frame (#184) (#189)
Vertical Shorts were played in a player forced to 100%x100% on a landscape
frame, so they looked wrong (pillarboxed/small). Option A: detect vertical at
ingest, persist it, and have every player honor it.

- Ingest (routes/content.js): detect a Short from the /shorts/ URL form OR
  portrait oEmbed dims (oEmbed now queried with the ORIGINAL url so /shorts/
  reports its true dimensions), and persist it as st_aspect=vertical on the
  stored embed URL. That's the only signal players get (remote_url), so it must
  be captured at ingest, not re-derived per loop. YouTube ignores the unknown
  param; players read the video id, not the full URL, to build the embed.
- Players read st_aspect=vertical and center a 9:16 box (fills a portrait screen,
  pillarboxes cleanly on landscape) instead of 100%x100%:
  web (player/index.html), Android (WebViewSupport.youtubeEmbedHtml), Tizen
  (player.js single-zone + zone paths). Dashboard library uses a static thumbnail,
  so it's unaffected.

Not doing Option B (yt-dlp): runtime dep + storage/bandwidth + maintenance +
YouTube ToS; embed-disabled Shorts already skip gracefully.

Tests: youtube-shorts.test.js (4) — /shorts/ and portrait-dims tag vertical,
landscape stays untagged, /shorts/ tags even if oEmbed fails. Android compiles;
web player inline JS + Tizen player.js parse.

Note: pre-existing Shorts added before this aren't retagged (would need an oEmbed
backfill) — re-add to fix, or a follow-up migration.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:41:43 -05:00
screentinker a15086540f
feat(widgets): directory-search widget (interactive search of a directory board, live-sync) (#188)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* feat(widgets): add directory-search widget

An interactive, walk-up search view of an existing directory-board. It
references a source board by id (no data copy), so a venue can run the
scrolling board on a main screen and a search view on a tablet, letting
people find an entry instantly.

Server (routes/widgets.js):
- register 'directory-search' + renderDirectorySearch(): resolves the source
  board, inlines its categories as one \u003c-guarded JSON blob, renders all
  text via textContent (XSS-safe), live case-insensitive filter over
  identifier/name/subtitle (debounced), grouped results, available styling,
  optional touch on-screen QWERTY keyboard that drives the same filter path.
- missing / non-directory-board source -> friendly full-page fallback, not a 500.
- live-sync while open is out of scope; left a // TODO for a poll hook.

Frontend editor (views/widgets.js): type + magnifier icon, source-board
dropdown (from loaded widgets, filtered to directory-board), title, logo
(reuses the board's picker), placeholder text, theme, on-screen-keyboard toggle;
getConfigFromForm case. i18n: widget.dirsearch.* + type keys in en/es/it/de/pt/fr.
docs: openapi widget_type enum. Tests: server/test/directory-search.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(widgets): live-sync for directory-search (poll source board, no reload)

Reflect directory-board edits on an open directory-search page without a reload.

- New public GET /api/widgets/:id/data.json returns { categories } for a
  directory-board (404 for missing/wrong-type so the page keeps last-good data
  on a transient miss). CORS-open (ACAO:*) + no-store so a null-origin sandboxed
  widget iframe can read it; exposes only data already public via /render.
  Exempted from CSP + auth in server.js alongside /render.
- directory-search page inlines its source_widget_id and polls the board's
  data.json every 30s via a relative URL (works behind a proxy/base path and
  from a null-origin iframe). Only rebuilds + rerenders when the data actually
  changed, so a mid-search view isn't disturbed; skips while document.hidden;
  keeps last-good data on any fetch error. Flatten logic factored into
  buildFlat() and reused by the poll.

Tests: data.json feed (categories, CORS header, 404s) + poll wiring in
directory-search.test.js (13/13). Verified live in a browser (page.clock
fast-forward): editing the board updates the search page with no reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): let player WebViews take touch focus for interactive widgets

directory-search is served through the existing generic widget path
(loadUrl <server>/api/widgets/:id/render), so it already renders on Android
with JS + DOM storage + mixed-content enabled, same-origin (so its live-sync
fetch of the source board's data.json works), and no touch blocking.

Add isFocusable/isFocusableInTouchMode to the shared WebView config so the
search field reliably takes a tap/cursor inside the kiosk lock-task WebView.
Harmless for passive widgets (board/YouTube have no focusable inputs); the
widget's own on-screen keyboard still drives the filter when the system IME
is suppressed. Verified with :app:compileDebugKotlin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:00:22 -05:00
screentinker 84ad89b06d
fix(dashboard): make auth-image hydration lazy by default (#182 follow-up) (#185)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
#182 shipped hydrateAuthImages() loading every thumbnail immediately, which
regressed the content-library grid from lazy to eager (a fetch per thumbnail on
render) and left the IntersectionObserver as dead code.

Restore lazy-by-default (observe-only) so large grids only fetch thumbnails as
they scroll into view, and add an { eager: true } opt-in for the transient
pickers where every item is on screen and immediate load reads better: the
device assign-content modal, the playlist add-item modal, and the widget
content picker. Grids and inline lists (content library, playlist items, device
playlist tab, directory logo/background) use the lazy default.

Behavior for those pickers is unchanged; only the large grids revert to the
lazy loading they had before #182.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:28:12 -05:00
Fabian Mendoza 00e8300af7
fix(dashboard): use data-auth-src for thumbnail images in modals and views (#182)
* fix(dashboard): use data-auth-src for thumbnail images in modals and views

Plain <img src> tags can't send the Bearer token, causing 403 on
/api/content/:id/thumbnail. Extracted loadAuthImage/hydrateAuthImages
from content-library.js into utils.js and applied the data-auth-src
pattern to playlists, device-detail, and widgets views.

Closes thumbnail rendering in:
- Playlist items list and add-item modal
- Device assignment list and assign-content modal
- Widget content picker, logo, and background images

* fix(dashboard): add requestAnimationFrame fallback for auth image hydration

The IntersectionObserver callback fires asynchronously and may miss
images on first render when the DOM layout isn't settled yet. Add a
rAF fallback that manually loads any still-unloaded images visible
within the viewport (same 300px margin as the observer).

* fix(dashboard): load visible auth images synchronously, not via observer

getBoundingClientRect() forces layout synchronously so visible images
load immediately. IntersectionObserver is now only used for lazy-
loading off-screen images. This eliminates the async timing gap on
first render where neither the observer callback nor rAF would fire.

* fix(dashboard): load all auth images immediately, skip visibility check

Simplifies hydrateAuthImages to load every img[data-auth-src] directly.
loadAuthImage deletes the attribute so observer double-fire is safe.
This eliminates any possible IntersectionObserver/BoundingClientRect
timing issues on first render.

* debug: add console logs to trace auth image hydration flow

* fix(dashboard): hydrate auth images in device detail initial load

loadDevice() renders the playlist tab with data-auth-src images
but never called hydrateAuthImages. Only the playlist-switch path
(line 1022) had the hydrate call. Added hydrateAuthImages to the
initial contentEl.innerHTML render so thumbnails load on first view.

* chore: remove debug logs, final clean version
2026-07-14 13:15:30 -05:00
screentinker b127ff5014
fix(pi-setup): portable Chromium package name (chromium-browser | chromium) (#183)
raspberry-pi-setup.sh bundled `chromium-browser` into the X11 apt-get install.
That package name only exists on Raspberry Pi OS / Ubuntu; on Debian it's
`chromium`, so the combined install hard-failed and `set -e` aborted the whole
installer on any Debian-based player.

Split Chromium out of the bundle into an install_chromium() helper that tries
`chromium-browser` then falls back to `chromium` (no-op if already present).
CHROMIUM_BIN detection already resolved either binary. Found by running the
installer end-to-end on a Debian arm64 VM (QEMU) standing in for a Pi.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:01:02 -05:00
Fabian Mendoza 87a1e02ff3
feat(dashboard): version loading indicator + immediate first poll (#181)
* feat(dashboard): show version loading indicator and fire poll immediately

- Show "Verificando..." while /api/version resolves on first load
- Fire first version poll immediately instead of waiting 15s
- Fallback to "-" when version is unavailable

* i18n: localize the version-check loading label

The sidebar version indicator hard-coded the Spanish string 'Verificando...',
shipping it to every user regardless of locale. Route it through i18n instead:
new 'common.checking' key (en: 'Checking...', es: 'Verificando...'); all other
locales fall back to the English canonical, matching the rest of the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: ScreenTinker <hello@screentinker.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:59:42 -05:00
ScreenTinker f10373551f chore(release): v1.9.7
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-07-13 23:10:57 -05:00
screentinker 51b0b006b1
fix(pairing): reinstalled panel reclaims its device row instead of being blocked [Bold] (#180)
Bold Media Group's fleet broke on the 1.9.3->1.9.6 upgrade. Their MDM does an
uninstall/reinstall (app data wiped), so the player registers with
{ pairing_code, fingerprint } and NO device_id and shows a pairing code — but the
dashboard reported "code does not exist". Deleting the device_fingerprints row fixed
it, which pinpointed the fingerprint-reclaim guard in server/ws/deviceSocket.js.

Root cause: the reclaim guard was
`stillAlive = !!liveConn || secondsSince < reclaimSettleSeconds; if (stillAlive) reject`.
On an in-place reinstall the old row heartbeat seconds ago, so `secondsSince < 300` is
ALWAYS true -> it emitted device:auth-error and returned BEFORE the pairing_code INSERT,
so the code the player displayed never existed server-side.

The settle window's real purpose was to REMATCH an existing fingerprint back to its
device row on reinstall — not to force a fresh re-pair. So the fix keys off claim status,
not the timer (server-only; no APK change — reviewed and confirmed unnecessary):
- Reject ONLY when the old row has a genuinely LIVE socket (liveConn) — the real anti-
  hijack boundary. Unchanged.
- CLAIMED old row (user_id set) -> RECLAIM it regardless of the settle window: reuse the
  row, rotate the token, emit device:registered{online} + device:paired. The panel returns
  straight to paired (no operator re-pair, no orphaned duplicate row), preserving name /
  claim / playlist / content. device:paired drives the app off the pairing screen, so the
  fresh code it showed is irrelevant.
- UNCLAIMED old row -> fall through to the pairing_code path and PROVISION FRESH with the
  shown code (reclaiming would leave a stale/null code -> "code does not exist"). #150
  relinks the fingerprint to the new row.
`reclaimSettleSeconds` is now vestigial for this path. Trade-off: a fingerprint-only reclaim
of a CLAIMED-but-offline device is no longer delayed ~300s — not a new attack class (the old
code already granted it once the window elapsed); liveConn remains the hard boundary. Truly
closing that window without a re-pair needs client keystore attestation (a future APK).

Also fixes a latent crash this newly exercises: middleware/subscription.js getUserPlan()
dereferenced an undefined user in its else branch ("Cannot set properties of undefined
(setting 'trial_active')") when the user/plan JOIN missed. Under the claimed-reclaim path
that ran checkDeviceAccess->getUserPlan, the throw was swallowed by the reclaim try/catch and
silently dropped the device to provision-fresh. Guard: `if (!user) return null`.

Tests (server/test/fingerprint-reclaim.test.js):
- NEW: a CLAIMED reinstall reclaims the SAME row, emits device:paired, creates no duplicate,
  keeps the fingerprint linked — regardless of the settle window (the Bold repro, fixed right).
- NEW: recent heartbeat + no live socket, UNCLAIMED -> provisions fresh with the shown code.
- NEW: a LIVE old socket still rejects and creates no new row (security preserved).
- Updated the #143 gone-device test to expect provision-fresh for an unclaimed row, and the
  log-noise assertion to the "reclaim rejected" message.
465/465 server tests pass. Server-only: NOT deployed, no version bump, Android untouched.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:07:50 -05:00
screentinker cf4c71d7d0
feat(email): SMTP transport as an alternative to Microsoft Graph [#173] (#179)
Adds a pluggable email transport so self-hosters without Azure/M365 can send
mail through any standard SMTP server (Postfix, Gmail, Mailgun, SendGrid, corp
relay). Graph stays the default; behavior is byte-for-byte unchanged when
EMAIL_TRANSPORT is unset or "graph".

- config: EMAIL_TRANSPORT ("graph"|"smtp", default graph) + SMTP_HOST/PORT/
  SECURE/USER/PASSWORD/FROM.
- services/email.js: branch by transport behind the SAME public sendEmail()/
  isConfigured() surface. SMTP via nodemailer (lazy-required, like MSAL).
  Shared across both transports: the "[ScreenTinker] " subject prefix (unless
  rawSubject), the GRAPH_DEV_RESTRICT_TO allow-list, html-from-text derivation,
  and the never-throws contract (failures log + return sent:false). SMTP_SECURE
  true=implicit TLS(465)/false=STARTTLS(587). Auth optional (unauthenticated
  relay ok); SMTP_USER without SMTP_PASSWORD is flagged. SMTP_FROM parses
  "Name <addr>". New emailConfigStatus() for startup diagnostics.
- server.js: startup logs the transport and a LOUD error when the selected
  transport is partially configured (some fields set, others missing) or when
  EMAIL_TRANSPORT is invalid (falls back to graph). A fully-unset transport
  stays a silent stdout fallback (unchanged dev behavior).
- nodemailer ^6.9.16 added as a production dep (bundled in the Docker image).
- .env.example + README: SMTP config section, Gmail example, transport table.
- test/email-transport.test.js: 15 tests — transport selection, config
  validation (missing/partial/invalid), SMTP message building (from/prefix/
  fromName override/text alt), sendEmail routing (mocked nodemailer), rawSubject,
  dev-restrict on smtp, and the smtp_error never-throws path.

462/462 server tests pass. Boot verified for all four states (configured,
misconfigured, invalid, default).

Closes #173

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:56:22 -05:00
ScreenTinker 406872c439 fix(server): serve /integrations/ hub explicitly so the nav link isn't the login page
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
express.static runs with index:false, so a bare /integrations/ fell through to the
SPA catch-all and rendered the dashboard login instead of the integrations hub — the
top-nav "Integrations" link, the canonical, and the sitemap entry all dead-ended at
login. Add an explicit route (like /agency, /sitemap.xml): /integrations/ -> the hub's
index.html, and /integrations -> 301 /integrations/. Spoke pages are real .html files
already served by static. Folded into a re-cut of v1.9.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:27:38 -05:00
ScreenTinker 3c59f647ac chore(release): v1.9.6 2026-07-13 12:12:03 -05:00
screentinker 85c1a9be7c
feat(seo): strengthen integrations internal linking + drop public status link (#178)
- Add "Integrations" to the top nav on the landing page and all 17 SEO pages
  (targeted at the nav Compare→Sign-In boundary; breadcrumbs untouched).
- Contextually cross-link the 6 integration spokes from the what-is and
  open-source guides (YouTube/RSS/Weather/Google Slides/Canva/Power BI) — each
  spoke now has 5 inbound links instead of hub-only.
- Remove the public "Status" footer link to /api/status from landing.html
  (endpoint stays for the admin panel + healthcheck; just not advertised).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:11:30 -05:00
screentinker c2115c88e3
fix(tizen): portrait/flipped VIDEO via AVPlay (hardware-plane rotation) — no more black screen (#170) (#174)
Bold field report: portrait video on Tizen = black screen. Root cause: our orientation
support rotates #stage with a CSS transform, but on Tizen the HTML5 <video> is composited
on a HARDWARE video plane that ignores CSS rotate — so portrait/flipped video blacks out
(images/widgets/text rotate fine). It's a Tizen platform limitation, not a regression.

Fix: route portrait / portrait-flipped VIDEO through Tizen AVPlay, whose setDisplayRotation
rotates the hardware plane itself. Scoped to portrait/flipped ONLY — landscape keeps the
proven HTML5 <video> path (double-buffer + group-sync drift), so the working case is untouched.

- index.html: <object id="avPlayer" type="application/avplayer"> hole-punch surface (hidden
  until a portrait video plays; off-hardware it's inert).
- player.js: setOrientation()/avAvailable(); renderVideoAv() (open/setDisplayRect/
  setDisplayRotation 90|270/setDisplayMethod LETTER_BOX/prepareAsync/play; onstreamcompleted
  -> loop|advance); avStop() torn down in clearStage() before every render; renderVideo()
  branches to AVPlay only for portrait/flipped when webapis.avplay exists.
- Graceful: any AVPlay error/absence -> honest note (portrait_video_unsupported, 5 locales),
  NEVER a silent black screen. currentVideoEl stays null for AV (portrait is solo-first;
  schedule engine still drives index/position).
- app.js: applyOrientation() now tells the player the orientation so renderVideo can choose
  the path.

Exit-signal marker slice unaffected (test green). Landscape + off-hardware paths unchanged.
NEEDS ON-DEVICE VALIDATION on a real Tizen TV (AVPlay can't be exercised off-hardware) —
signed test .wgt built for Bold.

Refs #170.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:26:27 -05:00
screentinker 9c70fcc790
feat(diagnostics): device incident log — offline cause, network-vs-reboot, display-sleep (#175)
* feat(diagnostics): device incident log — why a screen went offline/black, with device-attested cause

Field request (Bold/s_t_r_o_b_e): "screens go offline randomly — let us see the cause." Answers it
across the fleet with a unified incident log, and — the key insight — lets the DEVICE disambiguate
the cause the server can't: if the app process survived the gap it was a NETWORK problem (not a
reboot), and it can even tell a dropped Wi-Fi/Ethernet link from a link-up-but-server-unreachable
(router/upstream) failure.

Schema:
- device_status_log gains reason + detail (WHY each offline transition happened).
- NEW device_events table (unified incident feed): type (offline/online/display_off/display_on/
  crash/reboot/network/app_error) + reason + detail, indexed, age-pruned + per-device capped.

Server:
- Capture the socket.io disconnect REASON (transport_close/ping_timeout/transport_error) instead of
  discarding it — recorded in the offline-cause log. devices.offline_reason stays on the EXIT-SIGNAL
  contract (crashed/clean_exit/silent) — a separate axis, preserved (violent death = 'silent').
- device:event handler (typed incidents) + device:connectivity-report handler (device-attested).
  lib/incident-classify.js (pure, unit-tested) composes reason+detail: cold_start->reboot;
  link_lost->network "Wi-Fi/Ethernet link lost"; else network "LAN up, server unreachable
  (router/upstream)"; appends SSID / weak-signal (rssi<-75) / IP-changed. On a report it upgrades the
  most-recent offline row from the server's guess to the device's ground truth.
- heartbeat timeout -> 'heartbeat_timeout'; retention/cap for device_events.
- Device-detail API returns statusLog.reason/detail + the last 50 device_events.

Device (Android WebSocketService): ConnectivityManager default-network callback (link-lost during a
gap) + Wi-Fi SSID/RSSI + IP snapshot -> device:connectivity-report on reconnect (app survived =>
network); ACTION_SCREEN_ON/OFF receiver -> device:event display_on/off ("screen went black"). All
guarded/feature-detected; no manifest change; compiles clean.

Web + Tizen players: reconnect connectivity-report (link_lost from navigator.onLine during the gap)
+ visibilitychange -> display_off/on. Best-effort (no wifi detail in a browser). Tizen exit-signal
marker slice untouched.

CMS (device-detail): the offline cause on the uptime-timeline hover + a new "Recent incidents" panel
(merged offline periods + typed events, friendly labels, detail, relative time + down-duration).

Built as a 4-way parallel agent fan-out over disjoint domains against a locked contract, then
integrated. Verified: full server suite 443/443 (incl. the seam fix keeping the exit-signal contract
intact), Android compileDebugKotlin clean, all players + CMS node -c clean.

Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): internet-reachability probe — split "our server down" from "no internet" (#170)

Follow-up to the incident log: when a device's link is UP but it's offline, "router/upstream" was a
catch-all. The device now probes a public host (1.1.1.1 / 8.8.8.8 :443) DURING the gap, so the cause
pinpoints blame:
  - link_lost=true                     -> Wi‑Fi/Ethernet link lost (device's own link)
  - link up, internet_ok=true          -> server_down: internet reachable, OUR server was unreachable
  - link up, internet_ok=false         -> no_internet: router/ISP down
  - link up, no probe result           -> generic router/upstream (unchanged fallback)

- Android WebSocketService: fire a short daemon-thread TCP probe (443, either host) at disconnect;
  the result rides the connectivity-report as internet_ok (omitted if the gap ends before it finishes).
- lib/incident-classify.js: 3-way split on internet_ok; new reasons server_down / no_internet.
- Frontend i18n: device.event.server_down / .no_internet labels.
- Tests: +3 classify cases (server_down, no_internet, link_lost wins over internet_ok). 12/12.

Verified: classify 12/12, Android compileDebugKotlin clean, node -c clean. Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(diagnostics): log an 'upgrade' incident (old → new app_version) — server-side (#170)

When a device reports an app_version different from the stored one, applyDeviceInfo logs an
'upgrade' device_events row (detail 'old → new'). Server-side, so it covers Android/Tizen/web with
no client change; a fresh pair (no prior version) isn't counted. Adds device.event.upgrade label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:26:04 -05:00
screentinker 2dc1d1279a
feat(seo): IndexNow + landing-page optimization (schema, FAQ, CWV, content) (#177)
* feat(seo): enable IndexNow (key file + submission script)

Instant re-crawl pings to Bing/Yandex/Seznam/Naver on content changes (Google ignores IndexNow
but uses the same sitemap). Hosts the ownership key at frontend/<key>.txt (served at
https://screentinker.com/<key>.txt) and adds scripts/indexnow-submit.sh which POSTs the sitemap
URLs to api.indexnow.org (DRY_RUN=1 to preview). Run after a content deploy / from CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(seo): landing-page optimization pass (schema fixes, FAQ, content depth, CWV)

From a 3-way SEO audit (technical / structured-data / content). Highest-value fixes:

Structured data (penalty risk + rich results):
- REMOVE the fabricated aggregateRating (4.8/50) from SoftwareApplication — no visible reviews
  on the page = a "spammy structured markup" risk. Replace loose Offers with a proper AggregateOffer
  + publisher + image/screenshot.
- Add a VISIBLE FAQ section (10 Q&As) so the FAQPage schema finally has on-page content (it shipped
  4 Q&As with no visible counterpart — a mismatch); expand the FAQPage JSON-LD to mirror all 10.
- Add a WebSite entity block; add YouTube to Organization sameAs + a description.
- Fix the guides/compare BreadcrumbList position-2 target (dead /#features -> /).

Content / keywords / IA:
- Hero + Features subtitle rewritten to surface "digital signage software" / "digital signage CMS"
  / "self-host" / "free" above the fold.
- New "How It Works" (3-step) and "Use Cases / Industries" (8 verticals) sections for snippet +
  long-tail intent. Platform tiles (Android TV / Fire TV / Raspberry Pi) now link to their guides.
- FAQ answers add keyword-rich internal links to the guides + compare pages.

Technical / Core Web Vitals:
- Lazy-load the YouTube iframe (loading=lazy + youtube-nocookie + explicit width/height) — the top
  LCP/TBT win on mobile.
- Title 84->~60 chars (keyword-front), meta description ~178->~156 + CTA.
- <div> footer -> <footer> landmark; favicon sizes (192+512).
- sitemap.xml: add <lastmod> to all 10 URLs.

All JSON-LD validated (4 blocks parse; single H1; no fabricated data).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(seo): open-source/what-is pillars, Xibo+Anthias compares, integrations hub [#177]

Competitor-SEO gap-fill from the Yodeck/ScreenCloud/OptiSigns/Xibo teardowns.
11 new static pages + sitemap/landing/README wiring. All match the existing
seo-page.css template; BreadcrumbList on every page, FAQPage (visible-backed)
on guides + integration spokes. No fabricated ratings.

Pillar guides:
- guides/open-source-digital-signage.html  (head term "open source digital signage")
- guides/what-is-digital-signage.html       (TOFU definitional pillar + FAQ)

Comparisons (the open-source SERP Xibo/Anthias own):
- compare/xibo-alternative.html    (wedge: every ScreenTinker player free vs Xibo's paid Android/Tizen/webOS licences; no free plan)
- compare/anthias-alternative.html (wedge: fleet + video walls + multi-platform vs one-Pi-one-screen)

Integrations (OptiSigns' top tactic — one page per app):
- integrations/index.html hub
- google-slides / canva / power-bi  (honestly framed as the universal Webpage widget, with the X-Frame-Options / publish-vs-edit-URL caveat + Power BI public-data warning)
- youtube / rss / weather           (native widgets)

Wiring:
- sitemap.xml +11 URLs (lastmod 2026-07-13)
- landing.html Resources grid: 6 new cards (open-source, what-is, vs Xibo, vs Anthias, integrations hub)
- README.md: keyword-rich open-source/self-hosted intro + platform list + guide links (GitHub-SERP asset)
- docs/seo-directory-listings.md: off-repo G2/Capterra/AlternativeTo/awesome-selfhosted/fingoweb submission checklist + reusable kit

Validated: all JSON-LD parses, canonicals match paths, 0 broken internal links, sitemap well-formed (21 URLs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:22:37 -05:00
screentinker 12c0004245
test(ci): OS-assigned ephemeral ports for subprocess suites — kill the port-collision flake (#176)
The subprocess-booting test suites hand-picked fixed ports in a cramped ~3955-4021 range, and
156-schedule-read-path deviated to a RANDOM port (3900 + rand%90) that overlapped those fixed
ports. Under CI load two servers could race on the same port, surfacing as flaky "no such table:
devices" / "FOREIGN KEY constraint failed" (a server answering a request against a half-migrated
or wrong DB). It's environmental — the suites pass locally and in isolation.

Fix: a shared test/helpers/free-port.js (bind :0 on loopback, read the OS-assigned port, release)
called in before() so every suite gets a guaranteed-unique ephemeral port — concurrent suites can
no longer collide, and no one has to hand-assign ports.

- Codemod converted 30 suites: const PORT = <fixed|random> -> let PORT (+ BASE) assigned via
  `PORT = await freePort()` at the top of before().
- 3 hand-fixed (different structure): 148-eviction-storm (lowercase `base`), boot-health (no
  before() — allocates PORT + a throwaway SEED_PORT inside the test, replacing the hardcoded
  3894), totp-keyrotation (no before() — allocates at the test start before bootServer()).

No fixed 39xx/40xx ports remain. Full server suite 435/435; the 4 hand-touched suites pass in
isolation. Pure test-infra change — no app code touched.
2026-07-13 09:51:40 -05:00
ScreenTinker 40efaa1fed chore(release): v1.9.5
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-07-12 23:32:37 -05:00