Compare commits

..

290 commits

Author SHA1 Message Date
ScreenTinker 1f794ff7b4 chore(release): v1.9.1-beta1
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-06-12 22:37:50 -05:00
ScreenTinker 6add29bf6a fix(player): auto-relaunch after OTA self-update (#96)
After the OTA installs, PACKAGE_REPLACED kills the old process and nothing brought
MainActivity back, so updating screens dropped to the launcher (the 1.9.0 fleet bug). Add a
MY_PACKAGE_REPLACED receiver that relaunches via a shared Relauncher cascade (extracted from
BootReceiver so boot + post-update share one path):
  1. overlay-direct startActivity (SYSTEM_ALERT_WINDOW) - legal on all versions when granted
  2. full-screen-intent notification - auto-launches <14; on 14+ (USE_FULL_SCREEN_INTENT
     revoked) degrades to a VISIBLE, tappable "tap to resume" prompt - fail loud, never a
     silent dark screen

Emulator-proven on Android 16: MY_PACKAGE_REPLACED -> Relauncher[update] -> overlay-direct
(BAL_ALLOW_SAW_PERMISSION) -> MainActivity on the new version. Accessibility re-binds across
the package-replace (Service connected fires post-relaunch), so sequential OTAs keep their
auto-confirm.

Unattended OTA requires accessibility (auto-confirm the install) + overlay (relaunch); the
setup wizard grants both. A device where they're skipped degrades to the visible prompt.

Closes #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:32 -05:00
ScreenTinker 5bcaca7c51 fix(player): OTA install silently fails on Android 14+ (explicit PendingIntent)
UpdateChecker.tryPackageInstaller built the INSTALL_COMPLETE status PendingIntent with
FLAG_MUTABLE and an implicit intent. On Android 14+ (target SDK 34) that combination is
disallowed - getBroadcast() throws, the inner catch swallows it, and the PackageInstaller
session is never committed. Result: every OTA silently fails to install on a 14+ device
(download succeeds, version never changes). Make the intent explicit via setPackage(),
keeping FLAG_MUTABLE so PackageInstaller can still write EXTRA_STATUS back.

Emulator-proven on Android 16 (API 36): "Package installer session committed" and the
update applies. Distinct from the relaunch bug - this is install-on-14+.

Part of #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:32 -05:00
ScreenTinker 8d03741713 feat(server): make OTA observable - log update-check + apk-download hits (#96)
The OTA was invisible server-side: /api/update/check and /download/apk returned without
logging, which is part of why the 1.9.0 auto-relaunch failure went unseen. Log every
version check (client version vs latest, update_available, whether an APK is staged) and
every APK download (a device actually applying an OTA), keyed on the CF-aware getClientIp
so production logs show the real per-device IP behind Cloudflare, not the edge.

Observability for the #96 auto-relaunch work (this is how we'll watch the OTA fire during
the relaunch testing). Part of #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:29 -05:00
ScreenTinker f06a87f4be fix(api): harden device pairing against brute-force (#87)
The 6-digit pairing code is generated client-side, so the server can't raise its entropy
without a player change. Instead, harden server-side (no client change):
- lib/pair-lockout.js: lock an IP out of POST /api/provision/pair after 5 failed claims
  (15-min lockout), and expire stale provisioning codes after 15 min so a code is not
  claimable indefinitely. A successful claim resets the IP.
- /pair enforces both. Only an UNKNOWN code (404) counts toward the lockout (a real guess);
  an EXPIRED code (410) is a legitimate-but-stale code and does NOT count, so a slow bulk
  rollout from one shared-NAT IP can't lock itself out. getClientIp is Cloudflare-aware
  (CF-Connecting-IP validated against a trusted edge peer), so the lockout keys on the real
  per-client IP, never a shared edge.

Unit-tested deterministically with injected time, incl. the bulk-rollout-never-locks case.

Closes #87

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:16:12 -05:00
ScreenTinker 3305e79e61 fix(api): consolidate device pairing to /pair, remove vestigial bare endpoint (#90)
POST /api/provision was a second pairing endpoint that paired a device by code but,
unlike POST /api/provision/pair, did NOT assign a workspace, enforce checkDeviceLimit, or
emit device:paired / dashboard:device-added - a silently-diverging duplicate that no
client ever called. It now returns 410 Gone and points callers at /pair, so
/api/provision/pair is the single, fully-protected pairing endpoint. The mount stays in
the JWT-only partition, so a Bearer st_ token still gets 401 (requireAuth) before the 410.

Closes #90

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:13:16 -05:00
ScreenTinker 538f4a7b03 test(api): close #92 follow-up coverage gaps
The non-security gaps named in the public-API self-review:
- gap-fix: zone_id (playlist items) + layout_id (device PUT) accepted and returned on read,
  INCLUDING the cross-tenant rejection (the is_template OR workspace_id guard - the
  security-relevant one).
- docs serving: /openapi.yaml serves the spec, /docs returns the Redoc page.
- i18n drift-guard: apitoken.* keys have full parity across en/es/fr/de/pt (a key missing
  in one locale fails CI).
- token lifecycle branches: token-create workspace-membership validation and last_used_at
  stamping (integration), plus the must_change_password gate (unit test via the in-memory
  DB injection - cross-process WAL visibility is unreliable for that branch in-process).

119 tests total (was 108), all in the existing node --test job.

Closes #92

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:10:36 -05:00
ScreenTinker 33eaef826c test(api): fix spec scope drift + guard it in CI; Redoc provenance
Self-review follow-ups, kept as a separate commit so the review trail is honest.

- Spec drift: POST /widgets/preview was documented scope 'read' but the method-based
  tokenScopeGate enforces 'write' for any POST, so a read-token integrator following the
  published docs would hit a surprise 403. The code is right; fix the SPEC to match it.
- Guard it forever: test/openapi-contract.test.js cross-checks every spec operation's
  x-required-scope against the enforcement rule, and that every documented path is a
  public (token-reachable) router - both derived from the same config/api-surface.js.
  Adds js-yaml (devDep) to parse the spec. Spec/enforcement drift now fails CI.
- Vendored Redoc: add frontend/vendor/README.md (library, version 2.3.9, source, update
  steps) and drop the dangling //# sourceMappingURL line so /docs doesn't 404 in devtools.

Remaining (non-security) test-coverage gaps tracked in #92.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker 2ad9f54b8e test(api): token partition + threat-model + device WS coverage
A dedicated public-API suite (boots the real server as a subprocess) so CI green proves
the token layer, not just the pre-existing tests:

- Partition firewall, derived from the SAME config/api-surface.js server.js mounts from:
  every JWT-only router 401s a token; a public-surface snapshot fails if any router is
  added to the token door; known-privileged routers asserted JWT-only.
- Threat model: role-strip gates, workspace-binding both directions (token ignores
  X-Workspace-Id, JWT honors it), the scope ladder, the render bypass, token lifecycle,
  and JWT no-regression.
- Device WS round-trip via socket.io-client (added as a devDep): valid device_token
  registers + receives its playlist; wrong token rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker c1b9c27f3a docs(api): OpenAPI spec, Redoc at /docs, CI spec-lint
- docs/openapi.yaml: the public, token-reachable surface only, with the auth model
  (Bearer st_) and a per-operation x-required-scope (read<write<full). JWT-only routers
  are excluded by design.
- Serve /openapi.yaml + /docs (Redoc via a vendored standalone bundle, no CDN so it
  works air-gapped; /docs is CSP-exempt). docs/ is bundled into the release tarball.
- CI: redocly lint + a public-only guard that fails loudly if a JWT-only path ever leaks
  into the spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker dce0d22763 fix(api): expose zone_id + layout_id on the public write paths
- playlists: accept zone_id on item create + update, validated against a template or a
  layout in the playlist's workspace (no cross-tenant zone reference).
- devices: accept layout_id on PUT /api/devices/:id (symmetry with the layouts route),
  validated the same way; null clears it. Both are already returned in the GET SELECTs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker fab4ae909a feat(api): token management endpoints + Settings UI
- routes/tokens.js: create (returns the full secret once), list (never the secret),
  revoke. Mounted JWT-only via api-surface.js so an API token can never mint, list or
  revoke tokens - no self-escalation.
- Settings "API Tokens" section: create form (name + read/write/full scope), one-time
  secret reveal with copy, token list, revoke; i18n across en/es/fr/de/pt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker 73ca3cf258 feat(api): scoped API token foundation + secure-by-exclusion mounts
Introduce the public API's token layer and make the router partition data-driven.

- api_tokens table: SHA-256 hashed secret, st_ prefix, workspace-bound, read/write/full scope.
- middleware/apiToken.js: bearerAuth front door (Bearer st_ -> token auth, else the
  unchanged requireAuth); apiTokenAuth acts as the owner with platform powers stripped
  to 'user' and the workspace binding made authoritative (X-Workspace-Id ignored);
  tokenScopeGate (read=GET, write=mutations) + requireScope('full') for commands.
- config/api-surface.js: single source of truth for the PUBLIC (token front door) vs
  JWT-ONLY (requireAuth) router partition. server.js mounts from these lists so the
  mount list and the partition firewall test cannot drift.
- device-groups: operational group commands (reboot/shutdown) require the full scope.

A Bearer st_ token fails jwt.verify on the JWT-only routers (401), so privileged
surfaces (admin, workspaces, ai, provision, white-label) are unreachable by exclusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker 300d331562 fix(security): rate-limit the whole /api/provision pairing surface (#88)
Some checks are pending
CI / Unit tests (node --test) (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
POST /api/provision (the routes/provisioning.js router endpoint) pairs a device
by pairing_code with no rate limit - the limit at server.js:287 was bound only to
the /api/provision/pair override. An authenticated user could brute-force 6-digit
pairing codes against the bare endpoint to claim devices in the unclaimed pool.
Bind the rate limit to the /api/provision mount so it covers both pairing paths.

Verified: 6 rapid POSTs to /api/provision now 429 on the 6th (was unlimited);
/api/provision/pair still 429s on the 6th.

Closes #88

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:46:13 -05:00
ScreenTinker 11e339dd89 ci(release): make the pipeline prerelease-aware (#80)
Some checks are pending
CI / Unit tests (node --test) (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
Tagging a pre-release (e.g. v1.9.0-rc1) was unsafe. Four fixes:

1. bump-version.sh writes a numeric-only x.y.z to tizen/config.xml (strips a
   -rc1/-beta.N suffix) so the .wgt still signs/installs; the full VERSION
   (with the suffix) still drives server/Android/package.json.
2. release.yml flags the GitHub Release --prerelease for a -suffix version
   (keeps it off "Latest" and out of the /releases/latest API).
3. release.yml moves docker :latest only for final releases - a pre-release no
   longer repoints :latest onto untested code.
4. upgrade.sh excludes pre-release tags from its default selection - GNU
   `sort -V` ranks 1.9.0-rc1 above the final 1.9.0, so the unfiltered default
   would silently pick an RC (which then auto-OTAs to field kiosks). An explicit
   `upgrade.sh v1.9.0-rc1` still works.

Verified the strip, tag selection, prerelease/tags logic, and YAML validity in
isolation.

Closes #80

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:53:39 -05:00
ScreenTinker bd732f4c48 fix(android): zone image falls back to server URL when not cached (#78)
A multi-zone layout's zone rendered its image from the local content cache
only. If the content wasn't cached yet at first render (first-sync download
still in flight, or the preloader hadn't fetched that zone's content), the
zone drew blank - and a static (single, unscheduled) zone has no rotation
timer to redraw, so it stayed blank until the app was restarted.

Mirror the video branch: when getCachedFile returns null, load the image
straight from the server (the item's remote_url, else /api/content/<id>/file)
instead of leaving the zone blank.

Verified live on a 2-zone layout with two single-unscheduled items and fresh
content: both zones render with no restart, with only one item actually in
the on-device cache (the other displayed via the URL fallback).

Closes #78

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:40:23 -05:00
ScreenTinker 68367cb3a3 fix(settings): show the real app version in the About section (#83)
The settings "About" section hardcoded "ScreenTinker v1.4.1", so it never
reflected the running build (#/admin already showed the correct version).
Fetch /api/version in the async settings render — the same unauthenticated
endpoint the admin view uses — and render it (blank-safe on fetch failure).

Closes #83

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:12:39 -05:00
ScreenTinker 4b688fcfb1 chore(release): v1.9.0
Some checks are pending
CI / Unit tests (node --test) (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-06-11 21:11:31 -05:00
ScreenTinker ba8a71c4f5 docs(changelog): finalize 1.9.0 release notes 2026-06-11 21:11:30 -05:00
ScreenTinker 22376710ee fix(android): re-sign release APK with v1 (JAR) signature for MDM signage (#81)
minSdk 26 makes AGP default the v1 (JAR) signature off, so the release APK is
v2-only. Some MDM-managed commercial signage (MAXHUB via the Pivot MDM) silently
removes a v2-only app on the next reboot because its boot integrity check expects
a v1 signature — screens that power-cycle nightly lose the app and fall back to
the setup screen.

`enableV1Signing = true` has no effect at minSdk >= 24 (verified: still v2-only).
Instead, finalize assembleRelease with a `resignReleaseV1` task that re-signs via
apksigner with --v1-signing-enabled true and a low --min-sdk-version, emitting v1
alongside v2/v3. Verified: v1+v2+v3 at min-sdk 19, verifies at API 36, and the
re-signed APK installs and runs on a live API 36 emulator.

Closes #81

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:10:04 -05:00
ScreenTinker 3ddc209d19 docs(readme): add Wise donation link to Support section
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:37:07 -05:00
ScreenTinker 2ccf3264a9 feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Some checks are pending
CI / Unit tests (node --test) (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
Each playlist item can carry schedule blocks (active days, start/end
time-of-day, optional start/end dates). An item plays when the screen's
local "now" matches at least one block; an item with no blocks always
plays. #74 covers time-of-day/day-of-week windows including overnight
wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is
on-device, so dayparting and expiry work offline.

- Shared evaluator contract: shared/schedule-vectors.json (39 vectors —
  DST US+AU, overnight-wrap anchoring, timezone correctness, date
  boundaries). Canonical JS evaluator in server/lib/schedule-eval.js;
  Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff
  test, Kotlin JUnit reads the shared JSON, new android-test CI job).
- All three players (web, Android, Tizen) filter by schedule against their
  own clock, idle with a "Nothing scheduled" message + 30s re-check when
  everything is filtered, and fail open on any evaluator error.
- Editor: per-item schedule modal + row badge in the playlist editor;
  client validation mirrors the server; editing marks the playlist draft.
- Part B (behaviour change): device/group schedule overrides now evaluate
  in each device's effective timezone instead of server-local time.
- Device detail shows the reported timezone + a clock-skew warning.
- i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.*
  to avoid colliding with the device-schedule calendar's schedule.*).
- CHANGELOG documents the feature, the Part B change, the fail-open
  guarantee, and the scheduled-single-video re-render tradeoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:46:41 -05:00
ScreenTinker c8e664e66c fix(ws): guard fingerprint insert against stale device_id (FK violation noise)
A socket reconnecting with a device_id that no longer exists in `devices`
(e.g. the row was deleted server-side) hit the device_fingerprints insert
with an unknown foreign key. INSERT OR IGNORE does NOT suppress FOREIGN KEY
violations, so it threw a caught-but-noisy "Fingerprint tracking error" on
every such reconnect. Null out an unknown device_id before the insert; a
genuinely fresh device sends no device_id and was always fine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:46:41 -05:00
ScreenTinker e8a318e5fb chore(release): v1.8.3
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-06-11 09:38:42 -05:00
ScreenTinker 4d81bb112f fix(branding): inject instance branding into the app shell, no default flash (#76)
A never-visited org had no cached white-label, so brand-prime fell through to the
ScreenTinker default baked into the static index.html and flashed it before
branding.js fetched the org brand. Now the /app route injects the resolved
instance / custom-domain branding into the shell as a <meta name="ssr-brand">
(CSP blocks inline <script>, so a meta carries it), and brand-prime applies that
as the fallback when the per-workspace brand is not cached yet - so the page
paints the configured brand on first load instead of ScreenTinker.

- server.js: /app resolves branding (publicBranding strips internal columns) and
  injects the HTML-escaped JSON as a meta tag; falls back to plain sendFile on
  any error so branding can never break the app shell.
- brand-prime.js: read meta[name=ssr-brand] when there is no rd_branding_<ws>.

Verified: the meta carries the resolved brand (default ScreenTinker and a
platform-default white-label), internal columns do not leak, 66 unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:30:23 -05:00
ScreenTinker 53e32d31e2 fix(bump-version): do not rewrite the tizen config.xml XML declaration (#77)
bump-version.sh matched `<?xml version="1.0" ...?>` - the XML format version,
which has a leading space before version= just like the widget attribute - and
rewrote it to the app version, producing invalid XML that breaks the Tizen .wgt
build: 'XML version "X.Y.Z" is not supported, only XML 1.0 is supported'. (CI did
not catch it because the no-Tizen-CLI build path just zips the files without
validating the XML.)

- bump-version.sh: skip the `<?xml` declaration line in the tizen version sed.
- tizen/config.xml: restore the declaration to version="1.0" (prior bumps had
  corrupted it to 1.8.2).

The widget version and tizen:application required_version are still updated /
left alone correctly (verified with a dummy bump + an XML parse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:56:59 -05:00
ScreenTinker 3545830ea6 chore(release): v1.8.2 2026-06-11 08:42:57 -05:00
ScreenTinker c237a6fb27 fix(landing): correct comparison-table claims, mobile image, media-query bug
- Comparison table (landing + the 3 compare pages): correct cells against each
  vendor's current pricing/docs (verified June 2026). Delete the inaccurate
  Platforms, Content Designer, and Hardware Lock-in rows; relabel "Remote
  Control" to "Live screen view + remote key presses" with an Android/permission
  caveat; fix Video Wall, Kiosk, Free tier, White Label and remote cells for
  Yodeck, ScreenCloud and OptiSigns. Add an "as of June 2026 / report errors"
  footnote with a GitHub issues link.
- Compare pages: drop the false "supports more platforms than X" claims; correct
  Yodeck (Windows/ChromeOS, web player, kiosk), OptiSigns (free tier, video wall,
  white label, remote); add the same footnote + caveat.
- Mobile fix: .screenshot img now has max-width:100% / height:auto / display:block
  so the dashboard preview no longer distorts on phones (no desktop effect).
- CSS bug: restore the dropped @media (max-width:768px) wrapper (braces were
  102 { vs 103 }) so the mobile overrides stop leaking to desktop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:38:38 -05:00
ScreenTinker 10884ad87a docs: add RELEASING.md (bump -> push -> finalize ritual + ghcr note)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:22:53 -05:00
ScreenTinker e9c89343d7 chore(release): v1.8.1 2026-06-10 14:12:47 -05:00
ScreenTinker 5530d6cfcd docs: tag-based upgrade flow + upgrade.sh, Tizen install paths
- scripts/upgrade.sh: upgrade a self-hosted instance to a tagged release
  (default latest). Backs up the db (.backup), checks out the tag, npm ci
  --omit=dev, restarts the service (SERVICE_NAME override), reports the version.
- README: replace the git-pull update flow with scripts/upgrade.sh (latest or a
  pinned tag); keep main as the bleeding-edge option. Add a Samsung Tizen entry
  to device setup (URL Launcher -> /player).
- tizen/README: point path A at the server's built-in /player, and explain why
  the released .wgt is unsigned (Samsung distributor certs are DUID-locked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:12:29 -05:00
ScreenTinker fb17b242ce release: bundle .wgt in the CI tarball + finalize-release.sh for the signed apk
- release.yml: build the Tizen .wgt before the source tarball and bundle it in
  (ScreenTinker.wgt at the tarball root). The signed Android APK is added by the
  local finalize step (the keystore stays off CI).
- scripts/finalize-release.sh: after the release workflow publishes a tag, build
  the signed APK locally, pull the CI-built unsigned .wgt from the release,
  assemble a complete tarball (source + apk + wgt at the root, where /download/apk
  resolves the apk after extraction), and upload the apk + complete tarball.
- .gitignore: ignore *.wgt and *.tar.gz so finalize temp files cannot be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:12:29 -05:00
ScreenTinker 4f56199bc7 chore(release): v1.8.0 2026-06-10 13:46:17 -05:00
ScreenTinker 4771f62623 ci: release pipeline (tarball, tizen wgt, multi-arch docker) + Docker packaging
- .github/workflows/release.yml: on a v* tag - verify the tag matches VERSION
  (fail-fast guard), run tests, build a source tarball + the unsigned Tizen .wgt
  and publish a GitHub Release with generated notes, and build+push a multi-arch
  (amd64 + arm64) image to ghcr.io/screentinker/screentinker:<version> + :latest.
  The Release (artifacts) and the docker push are independent jobs, so an
  arm64/QEMU docker failure does not block the GitHub Release and is re-runnable.
  Nothing deploys to prod. APK-build-in-CI left as a TODO (keystore secret).
- Dockerfile + .dockerignore: multi-stage node:20-slim image with server +
  frontend + VERSION + scripts; DATA_DIR=/data volume for db/uploads/jwt-secret.
  Verified to build, boot, serve the dashboard + web player, and persist state.
- docker-compose.example.yml: /data volume, SELF_HOSTED, a node-fetch healthcheck
  against /api/status, and an admin-lockout recovery note (reset-admin.js).
- server.js: resolve the OTA APK from DATA_DIR first (a container can mount one
  at /data/ScreenTinker.apk), fall back to the legacy in-repo path, 404 gracefully.
- ci.yml: bump checkout/setup-node to v6 (clears the Node-20 action deprecation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:44:51 -05:00
ScreenTinker e2cd64054a ci: add CI workflow (unit tests + boot/version smoke)
- test job: node 20, npm ci + npm test in server/ (66 tests).
- smoke job: boot the server against a fresh SQLite db with SELF_HOSTED, then
  assert /api/status is ok and reports exactly the VERSION file (proves the
  single-source-of-truth wiring end to end).
- triggers: push and PR to main, plus manual workflow_dispatch. Concurrency
  cancels superseded in-flight runs per ref.
- upgrade-path job left as a TODO (needs a release tag earlier than HEAD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:24:46 -05:00
ScreenTinker 52b10408be chore(version): single-source VERSION, env-configurable data paths, bump tooling
- server/version.js: shared version helper that reads the root VERSION file once
  (fallback 0.0.0). Replaces the stale hardcoded 1.2.0 / 1.5.1 / 1.0.0 fallbacks
  in /api/version, /api/update/check, and /api/status.
- config.js: DATA_DIR / DB_PATH / UPLOADS_DIR / CERTS_DIR env overrides for the
  db, uploads, and certs/jwt-secret locations. Unset resolves to exactly the
  legacy in-repo paths, so existing installs (including production) are
  byte-for-byte unchanged. Guarded by test/config-paths.test.js.
- package.json: rename remote-display-server -> screentinker (+ lockfile name).
- scripts/bump-version.sh: one-shot bump across VERSION, package.json (+lock),
  android (versionName and versionCode + 1), and the tizen widget version; makes
  one commit plus an annotated tag; prints the push command, never pushes.
- .gitignore: global *.db / *.db-wal / *.db-shm / *.db.* so no database file
  (including .db.devbak backups, at any path) can be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:56:03 -05:00
screentinker 26cd29c530
Merge pull request #72 from screentinker/feat/player-orientation
feat(player): software orientation (portrait + flipped), Android + Tizen (1.7.12)
2026-06-09 21:43:12 -05:00
ScreenTinker dfc8a4e358 feat(player): software orientation (portrait + flipped) on both players (1.7.12)
The dashboard exposes landscape / portrait / landscape-flipped / portrait-flipped
and the README promises rotation, but neither player ever read the device's
orientation field - it was hardcoded landscape. Reported by a customer testing
Firestick + Samsung signage.

Rotate the CONTENT in software, not the panel: Fire TV / Android TV / Tizen are
fixed-landscape and ignore setRequestedOrientation (can't physically rotate).
- Android (MainActivity): applyOrientation() resizes rootView to the rotated
  dimensions, recenters, and rotates 0/90/180/270. rootView is the shared
  container for single-zone AND multi-zone, so both are covered. Driven from the
  playlist-update payload.
- Tizen (app.js): CSS transform on the stage (rotate + swapped 100vh/100vw),
  same four values, from the playlist payload.

Verified on an Android 16 emulator: device set to portrait -> 'Applied
orientation: portrait (rotation=90, swap=true)' and the video renders rotated.
2026-06-09 21:43:08 -05:00
screentinker f98bb57ab9
Merge pull request #71 from screentinker/feat/backup-script
feat(ops): nightly backup script with point-in-time content history
2026-06-09 19:53:12 -05:00
ScreenTinker 3ac81a4206 feat(ops): nightly backup script with point-in-time content history
Adds scripts/backup.sh — atomic SQLite .backup + hard-linked point-in-time
content snapshots, daily (7) + monthly (12) retention, and an error log.
Env-configurable (SCREENTINKER_DIR/BACKUP_DIR/DB/UPLOADS/*_KEEP*) so any
self-hoster can use it; defaults target a /opt/screentinker install.

Hardens two real failure modes found in production:
- Content snapshots EXCLUDE uploads/screenshots/ and use rsync --link-dest
  instead of cp -al. The per-device *_latest.jpg screenshots are rewritten
  24/7; cp -al aborts when a file mutates mid-copy and the prior script
  swallowed the error with 2>/dev/null, silently breaking content snapshots
  for ~8 weeks. rsync --link-dest hard-links unchanged files but tolerates
  in-flight changes; errors now go to backup.log.
- Retention sorts by NAME, not mtime: rsync -a / cp -al preserve the source
  dir's (frozen) mtime, so ls -dt treated fresh snapshots as oldest and pruned
  them. The timestamp is in the dir name, so name-sort is chronological.

README Backups section documents the cron setup + env knobs. Verified on prod.
2026-06-09 19:53:09 -05:00
screentinker 2cdf483f59
Merge pull request #70 from screentinker/chore/tizen-signing
chore(tizen): dev-signing setup + support email
2026-06-09 19:10:32 -05:00
ScreenTinker 5396cf9896 chore(tizen): dev-signing setup + support@screentinker.net author email
- config.xml author email -> support@screentinker.net
- build-wgt.sh: stage app files only before signing (keeps README/build script
  out of the .wgt), auto-add the Tizen CLI to PATH if installed.
- README: document the configured 'ScreenTinker' signing profile (self-signed
  author + default Tizen distributor) — installs on dev-mode TVs / emulator;
  production retail needs a Samsung distributor cert.

Signed .wgt + the author cert are not committed (build artifact / secret).
2026-06-09 19:10:28 -05:00
screentinker 6bcd193e45
Merge pull request #69 from screentinker/feat/tizen-player
feat(tizen): Samsung Tizen TV web player (.wgt)
2026-06-09 19:02:04 -05:00
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
ScreenTinker 8e7d599170 fix(widgets): no-store on widget/kiosk render
The render had no Cache-Control. A copy cached before the X-Frame-Options fix keeps
showing blank, and widget data (clock/weather/rss/directory) is dynamic anyway, so
mark the render no-store. Pairs with the X-Frame-Options removal.
2026-06-08 23:46:42 -05:00
screentinker 8dce93d4dc
Merge pull request #43 from screentinker/fix/widget-render-frameable
fix(widgets): make widget/kiosk render frameable (X-Frame-Options)
2026-06-08 23:37:52 -05:00
ScreenTinker 827b1c4c87 fix(widgets): make widget/kiosk render frameable (X-Frame-Options)
The web player embeds widget/kiosk renders in a sandboxed (allow-scripts, no
allow-same-origin) iframe = a null origin. The global helmet X-Frame-Options:
SAMEORIGIN refuses that (null != same-origin), so every widget rendered blank in
the web player (video worked since it isn't an iframe). Drop X-Frame-Options on
just the /render endpoints - the sandbox, not X-Frame-Options, is what isolates
the widget from the dashboard (it still can't read the JWT). Dashboard keeps its
clickjacking protection. Verified: directory board now renders in a sandboxed
iframe with no refusal.
2026-06-08 23:36:53 -05:00
screentinker d13ac58e74
Merge pull request #30 from screentinker/fix/widget-render-xss
fix(security): sanitize public widget render (stored XSS)
2026-06-08 23:20:38 -05:00
screentinker ac1b24fe43
Merge pull request #42 from screentinker/fix/sw-video-passthrough
fix(web): service worker video passthrough + independent per-zone rotation
2026-06-08 23:17:08 -05:00
ScreenTinker 68fb6a985e Merge remote-tracking branch 'origin/main' into fix/sw-video-passthrough
# Conflicts:
#	server/player/index.html
2026-06-08 23:15:32 -05:00
ScreenTinker 546fcdc105 fix(player-web): independent per-zone rotation in multi-zone layouts
Mirror of the Android fix. The web player showed only the FIRST assignment per
zone (playlist.find) and an image zone set the GLOBAL advanceTimer->nextItem, so
the whole layout re-rendered on one global tick instead of each zone cycling its
own content. Now each zone groups its assignments (by zone_id, sorted), renders
the first, and advances on its OWN timer (images/widgets/youtube: duration;
videos: on end; single-item zones loop). Cleared in teardown. Also render zones
before the single-item 'renderable?' bail so an empty current item can't blank
the screen.
2026-06-08 23:12:29 -05:00
ScreenTinker d4f71bbf3a fix(sw): stop the admin service worker from breaking video playback
sw-admin.js (scope '/') intercepted every non-API GET with clone+cache+respond.
Video requests are Range requests -> 206 Partial Content, which can't be cached;
cache.put threw and the handler errored ('ServiceWorker encountered an unexpected
error'), so .mp4s never loaded on any page this SW controls - including the web
player at /player, which then thrashed between items.

Now bypass (network-only) non-GET, Range requests, and /uploads//player/api/
socket.io; only cache same-origin 200s. CACHE bumped to v4 so clients pick up the
new SW + drop the stale bucket.
2026-06-08 23:08:13 -05:00
screentinker 6ef2cb548c
Merge pull request #33 from screentinker/fix/fullscreen-widgets
fix(android): widgets not rendering in fullscreen / single-zone layouts
2026-06-08 22:54:11 -05:00
ScreenTinker 5c0721b77f Merge branch 'main' into fix/fullscreen-widgets 2026-06-08 22:42:59 -05:00
ScreenTinker 3510670ce1 fix(android): YouTube Error 153 + visible web-frame errors
- YouTube: load the embed via loadDataWithBaseURL with a youtube.com base URL so
  the iframe has a valid origin/referer (a bare loadUrl of /embed/ID gives
  'player misconfigured, Error 153'). Applies to zone + fullscreen YouTube.
- Web frames: shared WebViewSupport.configure() enables mixed-content (self-hosted
  http LAN servers) and pipes WebView load/HTTP/JS-console errors to DebugLog, so a
  failing web frame surfaces the real error in the live panel instead of a black
  broken-page view.
2026-06-08 22:42:59 -05:00
ScreenTinker c184b94602 fix(android): log per-zone content switches (live debug)
After stopping the fullscreen controller in multi-zone, the only switch logs went
away - each zone now logs every item it renders (initial + each rotation) so the
live debug panel shows each zone advancing on its own interval.
2026-06-08 22:36:07 -05:00
ScreenTinker c94757fc97 fix(android): per-zone rotation + stop fullscreen controller in multi-zone
From Chris's live debug logs on the L-Bar layout:
- ZoneManager only rendered the FIRST assignment per zone -> the Main zone (3
  images) never rotated ('says it's switching but it's not'). Now each zone
  cycles its own assignments: images/widgets on a duration timer, videos on
  end (single-item zones still loop).
- The fullscreen PlaylistController kept running BEHIND the zones (playItem every
  10s, would leak audio for a zone video) because startIfNeeded() ran after every
  playlist update. Now only start it when not in multi-zone (zoneManager.hasZones).
- renderAssignments still called container.removeAllViews() (the same static-view
  nuke the cleanup() fix addressed) -> now removes only its own zone views.
2026-06-08 22:19:25 -05:00
ScreenTinker 73912d5f58 feat(debug): live per-device debug logging toggle on the device screen
Checkbox on the device-detail page streams the Android player's player/zone logs
live (no adb). Transient (off on reconnect), not persisted.

- Android: DebugLog util (logcat + optional socket emit); 'set_debug' command wires
  the sink + flag; key player/zone decisions (layout mode, playItem, per-zone
  render) emit through it.
- Server: relay device:log -> dashboard workspace room as dashboard:device-log.
- Dashboard: 'Debug logging' checkbox sends set_debug; live log panel streams lines
  (rendered via textContent; capped at 500).
2026-06-08 21:49:03 -05:00
ScreenTinker 50d7dbe222 fix(player): zone reset on multi->single layout switch + don't blank multi-zone
- Server (deviceSocket buildPlaylistPayload): when a device's layout has <2 zones
  (single or none), strip leftover zone_id from assignments. After switching a
  device from multi-zone back to fullscreen, content was stuck bound to a gone
  left/right zone_id and never played; nulling it lets both players fall back to
  the default fullscreen renderer.
- Web player: render multi-zone zones BEFORE the single-item 'renderable?' bail,
  so an empty/placeholder current rotation item can't blank the whole screen.
2026-06-08 21:31:27 -05:00
screentinker c1fbe165e7
Merge pull request #34 from screentinker/fix/sidebar-scroll
fix(ui): sidebar nav unscrollable on short screens
2026-06-08 21:21:00 -05:00
ScreenTinker 2e14de2069 fix(ui): make sidebar nav scrollable on short screens
On a short viewport (e.g. 1366x768) the sidebar nav was taller than the screen
with no scroll, so items below the fold (Settings) were unreachable. Add
overflow-y:auto + min-height:0 to .nav-links (the min-height:0 lets the flex
child shrink and scroll instead of overflowing).
2026-06-08 20:41:15 -05:00
ScreenTinker c7bbc4f815 fix(android): ZoneManager.cleanup must not remove the activity's static views
The black-screen on fullscreen widgets (and any single-zone playback after using
a multi-zone layout) was here: cleanup() called container.removeAllViews(), but
`container` is the activity root that also holds the static playerView/imageView/
youtubeWebView/statusOverlay. Removing them detached the WebView that the
fullscreen widget path reuses -> black. Remove only the zone views we added.
2026-06-08 20:34:30 -05:00
screentinker 171b69233c
Merge pull request #32 from screentinker/fix/android-device-fixes
fix(android): OTA APK signature verification (Critical) + pairing-code visibility
2026-06-08 20:08:58 -05:00
ScreenTinker 911cd07951 fix(android): render widgets in fullscreen / single-zone layouts
Widgets worked in multi-zone layouts (ZoneManager renders them in a WebView) but
were broken in "default fullscreen" (no layout) and the fullscreen template (a
single-zone layout) - both take the single-zone PlaylistController path, which:
  1) called getString("content_id"), throwing on a widget assignment (no
     content_id) - in both the playlist builder AND the pre-download loop, which
     could break the whole fullscreen playlist; and
  2) had no widget render case in playItem (so a widget never displayed).

Fix:
- PlaylistItem gains widgetId/widgetType + isWidget; the builder reads them and
  tolerates a missing content_id.
- playItem renders a widget fullscreen via MediaPlayerManager.showWidget() (loads
  /api/widgets/:id/render in the full-screen WebView, mirroring ZoneManager).
- Widgets auto-advance on their duration like images.
- Pre-download loop skips widget assignments (no file to fetch).

Compile-checked; signed APK builds. Needs on-device check: a widget plays in
default-fullscreen and the fullscreen template, and mixed widget+media playlists
advance correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:07:23 -05:00
ScreenTinker 60cda97b1d fix(android): stop pairing-code glyph clip + remove duplicate instruction
- The code's bottom was still clipped: the autosize TextView used wrap_content
  height, which clips glyph bottoms. Give it a fixed 96dp box (autosize 24-64sp,
  gravity center) so the text is centered inside a bounded box and never clipped.
- The "Enter this code…" line appeared twice (static label + statusText). Clear
  statusText when paired so it shows only once, with the code.
2026-06-08 19:53:44 -05:00
ScreenTinker 86340caf9d fix(android): keep pairing code fully on-screen (was clipped at bottom)
Follow-up to the provisioning layout fix - on a Pixel the code's bottom half was
cut off. Tightened the screen so the whole block fits:
- "RemoteDisplay" title 36sp -> 22sp, smaller subtitle + margins.
- Anchor content to the top (gravity center_horizontal|top) so the code sits
  high instead of being pushed below the fold by vertical centering.
- Pairing code autosize cap 96sp -> 56sp + vertical padding, so tall digits
  aren't clipped and the block stays on screen on short/landscape phones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:46:30 -05:00
ScreenTinker 06c6c3214b fix(android): make pairing code fit/visible on all screen sizes
Reported on a Pixel 10: the pairing code wasn't visible. The provisioning screen
was a non-scrolling vertical stack, and when the pairing section appeared below
the server-URL + Connect controls, the fixed 64sp code got pushed off-screen on
short/landscape phones (and could clip horizontally on narrow widths).

- Wrap the screen in a ScrollView (fillViewport) so content is always reachable.
- pairingCodeText now auto-sizes (autoSizeTextType=uniform, 24-96sp, single line,
  match_parent width) so it fills the width and never clips - phones, TVs, sticks.
- Hide the server-URL section + Connect button once paired so the code gets the
  full screen.

Compile-checked + signed APK builds. Needs on-device confirmation (Pixel 10 /
onn stick) that the code is now visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:19:55 -05:00
ScreenTinker d41bd1f27d fix(android): verify OTA APK signature before install + disable backup (Critical)
The updater fetched download_url from the server JSON and installed it via
PackageInstaller with NO verification, over cleartext (usesCleartextTraffic,
no pinning). A network MITM or compromised server could return a malicious APK
and have it silently installed (REQUEST_INSTALL_PACKAGES) → full device RCE.

Fix: before install, verify the downloaded APK (a) is our own package and
(b) shares a current signing certificate with the installed app
(GET_SIGNING_CERTIFICATES on P+, GET_SIGNATURES below). An attacker can't forge
our signing key, so this holds even over an untrusted/cleartext transport.
Fail-closed on any parse/verify error; the APK is deleted on mismatch. Gates
both the session-install and intent-fallback paths.

Also set android:allowBackup="false" so adb backup can't exfiltrate the
device token / config.

Compile-checked + signed debug APK builds. NOT verified on-device - needs a
real update cycle on a device (valid update installs; a wrong-signed APK is
rejected) before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:19:55 -05:00
ScreenTinker 401c4b00b5 fix(security): sanitize public widget render to close stored XSS
The public, CSP-exempt widget render (GET /api/widgets/:id/render) inlined
config values straight into <style>/CSS and (for the text widget) raw into the
same-origin document. A workspace editor could store `}</style><script>...` in a
color/background/size field (bypassing the UI pickers via the API) → stored XSS
executing in the app origin for anyone who opens the render URL (JWT theft).

- safeCss(): allow colors/gradients but reject CSS breakout / url() / @import /
  expression / javascript:. Applied to background/color across clock, weather,
  rss, social renders.
- safeNumber(): coerce font_size / scroll_speed / max_items to a finite number
  so they can't smuggle markup.
- Text widget keeps its intentional raw HTML/CSS feature, but it now renders
  inside an <iframe sandbox="allow-scripts"> (NO allow-same-origin) - scripts run
  in a null origin that can't reach the dashboard's localStorage/JWT.

Tests: test/widget-render-xss.test.js (breakout rejected, numbers coerced, text
isolated, legit colors/gradients preserved). Full suite green.
2026-06-08 19:11:14 -05:00
screentinker 50ad1f670b
Merge pull request #28 from screentinker/fix/security-quick-wins
fix(security): quick-win fixes from the codebase security review
2026-06-08 19:04:14 -05:00
ScreenTinker ba3e2cc785 fix(security): patch quick-win findings from the codebase review
Five low-risk, high-value fixes surfaced by the security review:

#3 Branding lockdown — `custom_domain`/`custom_css` (which feed the PUBLIC,
   pre-auth branding resolver and the login-page <style>) are now settable only
   by platform admins; a workspace_admin can no longer hijack the platform login
   page by claiming its domain. The public /api/branding (+ /domain) now return
   only presentational fields via publicBranding() (no id/user_id/workspace_id/
   custom_domain/timestamps leak).

#6 Strip device_token — the device WS auth secret (validated with
   timingSafeEqual) was returned in device list/get/update + pairing responses
   (SELECT d.* / *). New lib/device-sanitize.js strips it everywhere; prevents
   device impersonation by any workspace user.

#7 must_change_password enforced server-side — was a frontend-only redirect, so
   a provisioned temp password worked indefinitely via the API. requireAuth now
   403s every route except GET/PUT /api/auth/me (the password change, which
   clears the flag) and logout while the flag is set.

#8 XSS — escape user data interpolated into innerHTML in teams.js, kiosk.js,
   layout-editor.js (team/page/layout/zone names, member name/email, kiosk
   config fields). scriptSrcAttr 'unsafe-inline' made these exploitable via
   injected event handlers, not just markup.

#9 Thumbnail IDOR — /api/content/:id/thumbnail had no auth/scope gate (any UUID
   served any tenant's thumbnail). Now mirrors the /file route's playlist/widget
   workspace-scoped reference check.

Tests: new test/security-fixes.test.js (device strip, publicBranding field
allowlist, must_change_password gate). Full suite 41/41. Verified live against a
prod-data copy: device_token absent from /api/devices, /api/branding trimmed.

Not addressed here (tracked for follow-up): Android OTA signature verification
(Critical), public widget-render XSS, token revocation/logout, pairing-code
strength, validateRemoteUrl hardening, import quota.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:02:19 -05:00
ScreenTinker 66ef47239f fix(android): Android 14+ MediaProjection / foreground-service compliance (#5)
On Android 14+ (targetSdk 34) the app could fail to run at all on newer devices
(Pixel 10, onn HD stick). Root cause: the always-on WebSocketService called the
2-arg startForeground(), which claims EVERY foreground-service type declared in
the manifest - including mediaProjection. Android 14 rejects starting a
mediaProjection-typed FGS without a MediaProjection consent token, so the core
service threw on launch and the player never came up. Matches the reporter's
"screen recording policy" hunch - via the FGS type, not the capture trigger.

Fixes:
- WebSocketService now claims ONLY mediaPlayback (explicit
  startForeground(..., FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK), API>=29 guarded;
  2-arg on older). Manifest type narrowed to mediaPlayback.
- New MediaProjectionService (manifest type mediaProjection), started only AFTER
  the user grants consent. It enters the foreground with the mediaProjection type
  BEFORE getMediaProjection() (required on 14+), then drives ScreenCaptureService.
  The consent Activity now hands the result to this service instead of calling
  getMediaProjection() directly (an Activity can't hold that FGS type).
- ScreenCaptureService: register the MediaProjection.Callback BEFORE
  createVirtualDisplay() (Android 14 throws IllegalStateException otherwise).

Verified: Kotlin compiles, manifest merges (WebSocketService=mediaPlayback,
MediaProjectionService=mediaProjection), signed debug APK builds. NOT yet
verified on-device - needs a Pixel 10 / onn-stick run + logcat to confirm the
exact crash is resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:19:56 -05:00
screentinker d6e85b1745
Merge pull request #26 from screentinker/feat/global-default-branding-15
feat: instance-level default white-label branding (#15)
2026-06-08 17:03:39 -05:00
ScreenTinker eb13f716d0 feat(branding): instance-level default white-label branding (#15)
White-label is stored per-workspace (white_labels.workspace_id); unbranded and
new workspaces - and the login page - fell back to hardcoded ScreenTinker. Add a
single platform default that everything inherits beneath the per-workspace layer.

Resolution (lib/branding.js): workspace row -> custom-domain match -> platform
default -> hardcoded ScreenTinker. Row-level override: a workspace with its own
row keeps it (current behavior); only row-less workspaces inherit the default,
so editing the default propagates instantly (no row-copying at creation).

The platform default is a white_labels row with a FIXED id ('platform-default'),
not a "workspace_id IS NULL" sentinel - legacy pre-multitenancy rows can also
have a null workspace_id, which would be ambiguous.

- routes/admin.js: GET/PUT /api/admin/branding (requirePlatformAdmin) to read/
  upsert the single platform-default row; audit-logged.
- server.js: public GET /api/branding (domain match -> platform default ->
  hardcoded) for pre-login/pre-workspace contexts.
- routes/white-label.js: authed GET now falls back to the platform default
  (was hardcoded) for row-less workspaces.
- Frontend: login page resolves + applies branding (logo, name, colors, favicon,
  custom CSS) pre-auth; Admin page gets a "Default branding" form.

Tests: resolver order incl. legacy null-ws safety; admin GET/PUT (single row,
upsert, platform-admin-only 403). Full suite 37/37. Verified end-to-end:
public + authed + login-page all inherit the platform default; per-workspace
override preserved.

Closes #15.
2026-06-08 16:55:22 -05:00
screentinker 5433a97bc9
Merge pull request #25 from screentinker/fix/single-workspace-settings-19
fix(switcher): workspace settings inaccessible with a single workspace (#19)
2026-06-08 16:40:44 -05:00
ScreenTinker 3bf108d0fb fix(switcher): expose workspace settings for single-workspace users (#19)
The switcher's "manage members" + "rename/slug" affordances lived only in the
multi-workspace (>1) dropdown. A user with exactly one accessible workspace got
a plain static name with no way to reach org settings - so a fresh user with a
fresh workspace couldn't invite users, set permissions, or rename their slug.

Fix: the single-workspace view now renders the workspace name plus inline
manage-members + rename icons when the user can administer it (can_admin). No
dropdown for a single item.

Refactored the icon markup into adminIconsHtml(w) and the click wiring into
wireAdminIcons(scope, list), shared by the single-workspace view and the
dropdown items so the two can't drift again.

Frontend only. Verified headless: a fresh single-workspace admin now sees both
icons; clicking members navigates to #/workspace/:id/members and the members
view renders. Server suite unaffected (33/33).

Closes #19.
2026-06-08 16:39:42 -05:00
screentinker c1f2f0a637
Merge pull request #24 from screentinker/feat/searchable-org-switcher-16
feat(switcher): searchable / filterable org switcher (#16)
2026-06-08 16:32:45 -05:00
ScreenTinker 1f62ffbc3b feat(switcher): searchable/filterable org switcher (#16)
At MSP scale (100+ orgs) the org/workspace switcher dropdown was an
un-scrollable wall. Add a type-to-filter search box.

- Sticky search input at the top of the switcher menu, shown once the list
  reaches a threshold (>= 8 workspaces); below that the plain list is fine.
- Live client-side filter: case-insensitive substring match on
  "organization name + workspace name" (data-search haystack per row). The
  full list is already loaded from /me, so no extra requests.
- Keyboard nav: search is auto-focused on open; type filters, ArrowUp/Down
  move a highlight among visible rows, Enter selects (switches), Esc closes.
- "No matches" state when nothing matches; opening resets the filter.
- Refactored the switch action into a shared switchTo() used by both click
  and Enter.

Frontend only. Verified headless: filter narrows live, no-match state,
clear restores, arrow-key highlight. EN i18n added.

Closes #16.
2026-06-08 16:31:46 -05:00
screentinker 0f84cac440
Merge pull request #23 from screentinker/feat/admin-user-workspace-mgmt
feat(admin): manage a user's workspace memberships (multi + per-workspace role)
2026-06-08 16:26:07 -05:00
ScreenTinker 2872b883c7 feat(admin): manage a user's workspace memberships (multi + per-workspace role)
The Workspace column on the platform Users page could only move a 0/1-workspace
user and showed a dead "N workspaces" label for multi-membership users. Replace
it with a "Manage workspaces" modal that handles the full picture.

Backend (routes/admin.js, requirePlatformAdmin):
- GET    /api/admin/users/:id/workspaces            list memberships (+org/ws names, role)
- POST   /api/admin/users/:id/workspaces            add to a workspace (upsert role)
- PUT    /api/admin/users/:id/workspaces/:wsId      change role in a workspace
- DELETE /api/admin/users/:id/workspaces/:wsId      remove (last one allowed -> unassigned)
Roles validated against WORKSPACE_ROLES; each mutation writes an audit row.

Frontend:
- Workspace cell is now a summary (Unassigned / <name> / N workspaces /
  "Platform (all)" for staff) + a Manage button.
- New admin-user-workspaces-modal: lists every membership with an inline role
  dropdown + Remove, plus a type-to-filter "Add to workspace" picker (org-grouped,
  excludes current memberships) with a role select. Staff get a note that they
  already have platform-wide access. Refreshes the table on close if changed.
- Removed the old single-select inline move control (superseded by the modal).

Tests: 6 added (add to multiple workspaces, per-workspace role change, upsert,
remove incl. last->unassigned, validation 400/404, non-platform-admin 403).
Full suite 33/33. Verified headless: Manage opens, lists memberships, filtered
picker, add/role-change/remove round-trips persist (throwaway user, cleaned up).
2026-06-08 16:24:52 -05:00
ScreenTinker 66c95bb331 fix(db): cascade tenant resources on workspace/org delete (#18 follow-up)
The #18 user-delete bug was the first symptom of a broader gap: 13 tables
reference workspaces(id) (and activity_log also organizations(id)) with NO
ACTION, so deleting a workspace or organization fails the same FK wall once it
holds any content. SQLite can't ALTER an FK action, so this migration rebuilds
each table (the create-copy-rename pattern the assignments/schedules migrations
already use), changing only the tenant FK clause:
  workspace_id -> ON DELETE CASCADE   (resources belong to the workspace)
  activity_log.workspace_id / organization_id -> ON DELETE SET NULL (keep audit)

user_id FKs are intentionally left as-is - user deletion stays handled app-side
by lib/user-deletion.js (the #18 fix).

- lib/tenant-cascade-migration.js: pure, idempotent core (table-existence
  guarded; transforms the stored CREATE text, copies rows verbatim, recreates
  indexes; fixes activity_log's AUTOINCREMENT sequence; baseline-vs-after
  foreign_key_check so pre-existing orphan rows don't abort it but a botched
  rebuild does).
- db/database.js: boot wrapper owns the pre-migration snapshot + process.exit
  on failure, matching the other heavy migrations.

Tests (node:test): reproduces the workspace-delete FK failure, applies the
migration, verifies FK actions (CASCADE / SET NULL), index recreation, data
preserved, and that workspace/org delete now cascades (activity_log preserved).
Full suite 27/27. Verified on a copy of a real DB: 13 tables rebuilt,
integrity_check ok, workspace delete cascades, no new FK violations.
2026-06-08 16:01:52 -05:00
screentinker ec44cb785a
Merge pull request #21 from screentinker/fix/delete-user-fk-cascade-18
fix: user deletion fails with FOREIGN KEY constraint (#18)
2026-06-08 10:52:50 -05:00
ScreenTinker 05f9c20ecf fix(admin): user deletion failed with FOREIGN KEY constraint (#18)
DELETE /api/auth/users/:id ran a bare `DELETE FROM users`, but 23 columns
reference users(id) and only 4 cascade, so with foreign_keys=ON the delete
fails the moment the user is referenced anywhere - and a real user always is
(owns an org, created a workspace, has login activity). Reproduces on a fresh
DB, exactly as reported.

The schema also lacks cascades from workspaces -> tenant resources, so the DB
can't clean up on its own. New lib/user-deletion.js resolves every reference in
one transaction (defer_foreign_keys=ON for forgiving order; table-existence
guard for resilience):
  - Refuse (409) if the user OWNS an organization that has other members -
    don't nuke a shared tenant; transfer ownership first.
  - Hard-delete the organizations they SOLELY own (workspaces + all contents).
  - In orgs they don't own, PRESERVE resources: SET NULL the nullable
    creator/inviter columns, and reassign the NOT NULL legacy creator user_id to
    the resource's org owner (fallback: the acting admin).
  - Memberships (organization_members/workspace_members/team_members/
    content_folders) cascade on the user delete; pending invites they sent and
    legacy teams they own are removed.

The handler now 404s an unknown id and 409s the shared-org case.

Tests (node:test): reproduces the FK failure, then verifies provisioned-member
delete (resources preserved + unlinked/reassigned), solo-org-owner cascade,
shared-org refusal (409), self-delete 400, non-superadmin 403, unknown 404.
Full suite 22/22. Verified end-to-end on a copy of a real DB: deleted a user
owning 2 solo orgs, foreign_key_check clean.

Closes #18.
2026-06-08 10:51:32 -05:00
ScreenTinker 7615eabdd5 feat(admin): Workspace column + inline move/assign on the Users page
Adds a "Workspace" column (after Plan) to the platform Users admin table so a
platform_admin can see and reassign a user's workspace inline, alongside the
Role/Plan dropdowns. Single-workspace move/assign model.

Backend:
- GET /api/auth/users (platform branch): one aggregate query adds
  workspace_count and, for exactly-one membership, the workspace id/name + org
  name (no N+1).
- PUT /api/admin/users/:id/workspace (requirePlatformAdmin - operator excluded):
  move (1 membership) or assign (0) into the chosen workspace, default role
  workspace_viewer, in a transaction; no-op if already there; REFUSES (400) a
  user with >1 membership (manage in the members view). logActivity
  admin_set_user_workspace.

Frontend (admin.js):
- Editable <select> only for a 'user' with 0/1 membership; multi-membership ->
  read-only "N workspaces", platform staff -> read-only "Platform (all)".
- Options grouped by org via <optgroup>, built ONCE from /me's
  accessible_workspaces (same source as the Add User picker) and reused per row.
- Picking "Unassigned" or the same workspace is a no-op so a stray pick can't
  strip a membership. Success -> toast + refresh. EN i18n only.

Tests: 4 added (single-membership move 200 + changed, zero-membership assign
200, multi-membership 400 refused, non-platform-admin/operator 403). npm test
16/16. Verified headless: column renders, selected value correct, "Platform
(all)" for staff, and a dropdown move persisted (throwaway user, cleaned up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:34:47 -05:00
ScreenTinker 65691e26da chore(admin-sw): bump cache to v3 to evict stale clients
Force returning browsers to drop the old service-worker cache bucket so the
new platform Users "Add user" button lands. The SW is already network-first;
bumping CACHE (rd-admin-v2 -> v3) changes the SW bytes, which makes the browser
detect a new worker and run activate(), deleting every cache key != CACHE.
Also rescues any client still stuck on the pre-v2 cache-first worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:34:47 -05:00
ScreenTinker 400872f8ea feat(admin): Add User from the platform Users page (workspace picker)
Extends the shared add-user modal (workspace-members-add-user-modal.js) with
an optional picker mode instead of forking a second form:
- opened with a fixed workspace (members view) -> unchanged, no picker;
- opened with null (platform Users admin page) -> shows an Org/Workspace
  picker (type-to-filter over /me's accessible_workspaces, labelled
  "org / workspace") plus the role select; email/name/password+generate/
  must-change/error-mapping stay shared.

Role options are rendered from a single WORKSPACE_ROLES constant that mirrors
the set POST /api/admin/users accepts (routes/admin.js) - so we never offer a
value the endpoint 400s (the platform_operator mismatch we already hit).
org_admin is intentionally NOT offered: the endpoint accepts only the three
workspace roles.

admin.js: "Add user" button in the page header (page is already
platform_admin-gated; the endpoint additionally enforces canAdminWorkspace,
which platform_admin passes everywhere). On success -> toast + refresh the
user list. Reuses workspace-members.js's mapMutationError. EN i18n only.

Frontend only - no backend change. Verified headless (Playwright): button
opens the modal, picker lists all 45 workspaces with working filter, role
options = [viewer, editor, admin], and submit created + assigned a user into
the chosen workspace (test row cleaned up afterward). npm test still 12/12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:34:47 -05:00
screentinker 9aae64c47a
Merge pull request #20 from screentinker/docs/surface-disable-registration
docs: surface DISABLE_REGISTRATION self-hosting flag (#11)
2026-06-08 10:30:13 -05:00
ScreenTinker 406f481a57 docs: surface DISABLE_REGISTRATION self-hosting flag (#11)
DISABLE_REGISTRATION already closes public self-service signup (first-user
setup on an empty DB still allowed) and the login page already hides its
"Create account" button when it's set - but the flag was easy to miss: it was
in the README env-var table yet absent from .env.example (the file
self-hosters actually copy) and from the README systemd unit example.

- .env.example: document DISABLE_REGISTRATION + DISABLE_HOMEPAGE under the
  Self-hosting section.
- README: add commented Environment= lines for both to the systemd example,
  noting the login UI hides the signup button to match.

Docs only - no code change. Backend gate (routes/auth.js canRegister +
/auth/config registration_enabled) and the login.js hiding already behave
correctly; verified registration_enabled flips to false under the flag.

Closes #11.
2026-06-08 10:29:08 -05:00
screentinker 212170eb88
Merge pull request #17 from screentinker/feat/role-model-and-admin-users
Role model + MSP user provisioning (#14, #13, #10, #12)
2026-06-05 13:44:46 -05:00
ScreenTinker 5502a3eaa8 fix(roles): make platform_operator assignable + add deny/assign regression tests
The bug: #13 added 'platform_operator' to the frontend role dropdown
(PLATFORM_ROLE_OPTIONS) but #14's PUT /api/auth/users/:id/role whitelist
(ASSIGNABLE_PLATFORM_ROLES) only listed ['user','platform_admin'], so
selecting "Platform operator" returned 400 "Invalid role" - the role was
unassignable via the UI.

Fix: add 'platform_operator' to ASSIGNABLE_PLATFORM_ROLES. One line; the
self-demote guard is intentionally left untouched (a platform_admin still
cannot self-assign the non-owner operator role and lock themselves out).

Tests (node:test, isolated in-memory DB injection - no DB_PATH change):
- admin-users.test.js: platform_admin can PUT role=platform_operator on a
  target user -> 200 and the row persists as platform_operator (regression
  guard for the whitelist gap).
- operator-permissions.test.js (new): verify-then-test of the highest-blast
  -radius deny. Operator CAN update/delete a workspace-scoped content row
  (cross-org write works) but is denied (403) updating or deleting a shared
  (workspace_id IS NULL) row - proving the separate PLATFORM_ROLES gate in
  content.js's checkContentWrite still holds after canWrite was broadened to
  isPlatformStaff.

Verified read-only (no leak): the other shared-asset write sites keep their
PLATFORM_ROLES gate that excludes operator - kiosk.js:57, widgets.js:110,
folders.js:31, layouts.js:59/117/133.

cd server && npm test -> 12 pass / 0 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:44:39 -05:00
ScreenTinker 7674f6dc9f test(admin): node:test coverage for Add User + role gating
Adds server/test/admin-users.test.js and a `npm test` (node --test) script.
No DB_PATH override: the suite mounts the real routers against an isolated
in-memory better-sqlite3 instance injected into the require cache, seeded by
the test itself. Node v20 built-ins only (node:test, node:assert, fetch).

Covers: Add User success (response omits password/hash, hash stored not
plaintext, membership written, hosted lifecycle sentinels stamped, audit row
without the password), duplicate-email 409 (no overwrite), non-admin 403,
platform_operator denied (403), org_admin scoped to their own org only,
input validation, and the must_change_password lifecycle (set on create,
surfaced on login, cleared on PUT /api/auth/me).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:23:06 -05:00
ScreenTinker 54549420e7 feat(signup): optional org-on-create for self-service signups (#12)
MSP-style deployments want self-service signups created WITHOUT a personal
org, so an admin/operator can assign them into an existing customer org
afterward.

- config.autoCreateOrgOnSignup (AUTO_CREATE_ORG_ON_SIGNUP env), default
  true - single-tenant and the hosted self-service flow are unchanged.
- ensureDefaultOrgForUser gains { allowCreate }: an existing membership is
  always returned (idempotent); the MINT path is gated. allowCreate=false +
  no membership -> returns null (user created org-less).
- register accepts a per-request createOrg flag overriding the deployment
  default; the first-ever user is always given an org (never headless).
  login / Google / Microsoft pass allowCreate from the global config, so an
  org-less user is not silently given an org on next sign-in.

Edge case: a non-platform user with zero workspaces now lands on a "no
workspaces yet" empty state (new no-workspace view) instead of being bounced
into onboarding (whose pairing step needs a workspace). route() redirects
them there, and refreshCurrentUser() redirects once /me reveals zero
accessible_workspaces (covers the first-load race). The workspace switcher
already rendered an empty placeholder and resource routes already return []
for a null workspace, so nothing crashes in between.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:16:27 -05:00
ScreenTinker 6e31770cee feat(admin): admin-provisioned user creation + first-login gate (#10)
Adds POST /api/admin/users so an admin can create a user directly with a
known password and assign them to a workspace + role - for self-hosted
instances with no outbound email, where invites never deliver.

Server (routes/admin.js, mounted /api/admin with requireAuth + activityLogger):
- Gated by canAdminWorkspace(db, req.user, targetWorkspace): 404 if the
  workspace is missing, 403 if not an admin of it. This scopes org_admins
  to their own org and excludes platform_operator (no user/role mgmt, #13).
- Validates email (invite-create regex), role in WORKSPACE_ROLES, password
  min-8 (the /me rule). 409 on duplicate email - never overwrites.
- One transaction: global users row (auth_provider 'local',
  bcrypt.hashSync(pw,10), must_change_password from the flag) + a
  workspace_members row written inline (same footprint as an accepted
  invite; accept-invite left untouched).
- Explicit audit row admin_create_user; never logs the password; response
  excludes password/hash.
- HOSTED_INSTANCE: never calls sendSignupEmails and stamps both
  welcome_email_sent_at / activation_nudge_sent_at, so an admin-created
  user gets no welcome email and never enters the activation-nudge sweep.

must_change_password (frontend-first enforcement, per spec):
- Migration adds users.must_change_password INTEGER NOT NULL DEFAULT 0;
  surfaced via requireAuth + /me + login responses.
- route() in app.js forces users with the flag to a #/change-password
  screen (new force-password-change view, reuses PUT /api/auth/me) and
  blocks every other view until set. The /me update clears the flag.

Frontend: "Add User" button beside "Invite member" in the members view
(admin-only) opening a modal (email, name, password + generate, role,
must-change checkbox); invite and Add User coexist. api.adminCreateUser;
EN i18n only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:03:56 -05:00
ScreenTinker 48902f6807 feat(roles): add cross-org platform_operator staff role (#13)
platform_operator is cross-org STAFF: it can see and act-as into every
org and read/write workspace-scoped resources (content, playlists,
layouts, schedules, devices, widgets, kiosk) anywhere - but holds NO
owner-level power.

Design is deny-by-default: operator is NEVER added to PLATFORM_ROLES /
isPlatformRole, so every owner capability (billing, org/workspace
deletion, user/role management, shared & template asset curation,
branding, workspace member mgmt/rename) stays denied, and any NEW owner
endpoint added later inherits that denial automatically.

Operator gets power from exactly two levers:
- middleware/auth.js: new PLATFORM_STAFF set + isPlatformStaff(); owner
  guards (PLATFORM_ROLES, requireAdmin, requireSuperAdmin) unchanged.
- tenancy.js: accessContext + resolveTenancy treat staff as act-as
  capable; new req.isPlatformStaff / req.isPlatformOperator (req.isPlatformAdmin
  stays owner-only); accessibleWorkspaceIds + switch-workspace guard use staff.
- permissions.js: canRead/canWrite + canAccessWorkspace (read) grant staff;
  canAdmin / canAdminWorkspace / isOrgAdmin / isOrgOwner stay owner-gated.

Read-only edges (per review): operator may VIEW workspace member lists
(canAccessWorkspace) and the unassigned device pool (devices.js), but
cannot mutate either.

Frontend: platform role dropdown adds "Platform operator"; the user-mgmt
view stays isPlatformAdmin-gated so operators can't open it. EN i18n only.

Behaviour identical under HOSTED_INSTANCE set or unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:30:21 -05:00
ScreenTinker 797eab7c8d refactor(roles): normalize the platform-role model (#14)
The legacy /api/auth/users dropdown could write 'superadmin' and 'admin'
role strings that not every code path recognized. Some checks matched only
'platform_admin' (tenancy accessContext/resolveTenancy), so a 'superadmin'
user could list orgs but not act-as into them.

Normalize to the current two-tier platform model (users.role holds the
PLATFORM role only; org/workspace roles live in the membership tables):

- Migration (idempotent, exact-string): superadmin -> platform_admin,
  admin -> user. No-ops on rows already in the current model.
- Add isPlatformRole() helper in middleware/auth.js; route the two
  superadmin-excluding checks in tenancy.js through it so a stray
  'superadmin' is never treated as lower-privileged (fixes act-as).
- Remove the dead/stricter requirePlatformAdmin in permissions.js (bare
  === 'platform_admin'); the single guard is the one in middleware/auth.js.
- Recovery-token default role admin -> platform_admin so emergency
  recovery keeps full access once 'admin' no longer implies elevation.
- PUT /api/auth/users/:id/role whitelist -> ['user','platform_admin'];
  self-demote guard retargeted via isPlatformRole.
- Frontend: platform user-management dropdown now offers User / Platform
  admin only; owner-delete guard and settings highlight use isPlatformAdmin.
  EN i18n: add admin.role.platform_admin.

Behaviour is identical under HOSTED_INSTANCE set or unset; the migration
only touches exact legacy strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:58:46 -05:00
ScreenTinker 0fec335e75 docs: add Android player troubleshooting & recovery guide
Covers the "Connecting to server" / xhr-poll-error hang (stale server URL,
fixed via Clear data + re-provision), and adb-over-Wi-Fi setup including the
gotchas: must be on the same subnet, and never `adb root` over a wireless
connection (it wedges adbd until reboot). Linked from the README Device Setup
section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:58:09 -05:00
ScreenTinker 890ec5790f fix(proof-of-play): throttle play_logs writes to prevent runaway bloat
A player stuck in a tight loop (playlist with 0-second item durations)
fires device:play-event 'play_start' ~3x/sec, inserting a play_logs row
each time. Three web players doing this generated ~909k rows (99.9% with
duration_sec=0) and grew the prod DB to 265 MB.

Throttle proof-of-play inserts to at most one per device per 2s (in-memory
lastPlayLogAt map). Skipped cycles create no row; the live dashboard
progress event still fires every time, so the UI is unaffected. The
play_end UPDATE only closes open rows, so throttling play_start is safe.

(Junk rows already pruned in prod: 909k deleted, DB 265 MB -> 9.8 MB.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:52:22 -05:00
ScreenTinker cbe00d6c85 feat(signup): T+3 activation nudge for users with zero paired screens
Daily sweep (15:00 UTC) emails a warm, personal "checking in" message
to users who signed up 3-14 days ago and still have no paired screen,
nudging them toward activation. Once per user, reuses the Graph
transport (services/email.js) via the existing fromName/rawSubject
options.

- New service services/activationNudge.js, started from server.js.
  Self-correcting daily scheduler (recompute next 15:00 UTC each run;
  no node-cron dependency).
- Eligibility (Option B, workspace-aware): created 3-14 days ago,
  activation_nudge_sent_at IS NULL, COALESCE(email_alerts,1)=1 (only
  an explicit opt-out of 0 is excluded; NULL/unset still qualify), and
  ZERO devices owned by the user OR present in any workspace they
  belong to. The workspace check avoids nudging engaged team members.
- Idempotency: activation_nudge_sent_at, stamped after send; paired
  sentinel-1 backfill so the first sweep can't blast the dormant
  legacy base. Only genuinely-new signups become eligible.
- GATE: HOSTED_INSTANCE=true (positive hosted signal, NOT !selfHosted).
  A daily bulk sweep would be far worse to leak than a single email, so
  a self-hoster who configured Graph but missed SELF_HOSTED won't blast
  their user base. Unset -> neither scheduled nor sent. Documented in
  .env.example.
2026-05-30 20:28:24 -05:00
ScreenTinker 2f78fa1106 chore: track .env.example (un-ignore from .env.* rule)
The prior commit's .env.example was silently dropped by the .env.*
gitignore rule. Add a "!.env.example" negation so the documented
template (placeholders only, no secrets) is tracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:16:52 -05:00
ScreenTinker c0b220836a fix(signup): make admin-notify recipient env-driven, not hardcoded
The admin signup-notify recipient was hardcoded to
support@screentinker.com and shipped in the open-source code. Combined
with the opt-out SELF_HOSTED gate, any self-hoster who configured their
own Graph credentials but forgot SELF_HOSTED=true would fire their
users' signup PII (email, IP, country) into our support inbox.

Source the recipient from ADMIN_NOTIFY_EMAIL instead, defaulting to
null. When unset, the admin notification is skipped entirely and logged
("[SIGNUP-EMAIL] admin notify skipped (ADMIN_NOTIFY_EMAIL unset)"); the
user's welcome email is unaffected. Hosted prod sets the env var so its
notifications continue; self-hosters send nothing to us by default, and
the .com address no longer ships in code.

Document ADMIN_NOTIFY_EMAIL (and the related mail/self-host vars) in a
new .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 16:16:33 -05:00
ScreenTinker a0abdc01ed fix(signup): route admin signup notification to support@ not personal inbox
Admin signup notifications were going to dw5304@gmail.com. Route them
to the monitored support@screentinker.com queue instead, so signups
land in the shared inbox rather than a personal account.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:52:59 -05:00
ScreenTinker b67fbaa1b6 feat(signup): welcome email + admin signup notification (slice 1)
Every new user now gets a personal welcome email from
"Dan at ScreenTinker" <support@screentinker.com>, and Dan gets an
admin notification, immediately after signup. Fired from all three
signup paths (local /register, Google, Microsoft) via a shared
helper (services/signupEmails.js) at the new-user branch only, so
OAuth logins of existing users don't re-trigger.

- Reuses the single Microsoft Graph transport (services/email.js).
  Adds two optional, backward-compatible params: fromName (custom
  From display name; address stays support@ so replies route there)
  and rawSubject (skip the "[ScreenTinker] " prefix for clean
  subjects "Welcome to ScreenTinker" / "New signup: <email>").
- Idempotency: users.welcome_email_sent_at, stamped after the send
  block; non-null short-circuits so a user is only emailed once.
  Paired backfill stamps all pre-existing users with sentinel 1 so
  a future "IS NULL" sweep can't mistake the legacy base for
  un-welcomed and blast them.
- Production-only: gated on !config.selfHosted so self-host
  operators never emit mail from our domain or CC Dan.
- No retry logic by design (no re-trigger path on existing users);
  per-email {sent, reason} is logged so a Graph hiccup is visible.

Admin notification includes workspace org name, email, UTC + Central
timestamp, client IP (CF-aware), CF-IPCountry, and user agent.
2026-05-30 14:50:27 -05:00
ScreenTinker d7e3ae6076 security(widgets): tighten webpage widget inner sandbox
The webpage widget's inner iframe previously declared
sandbox="allow-scripts allow-same-origin", which was
functionally stripped to "allow-scripts" by the outer
iframe sandbox added in fe36c8c. This commit makes the
declared sandbox match the actual effective behavior.

Closes the remaining piece of issue #8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:14:31 -05:00
ScreenTinker fe36c8c4b9 security(widgets): add sandbox="allow-scripts" to widget iframes
Addresses the primary finding from the May 27 security report (issue #8):
the admin widget preview modal (frontend/js/views/widgets.js) and the web
player widget renderer (server/player/index.html, 2 sites) loaded
user-authored widget HTML into unsandboxed iframes. Same-origin scripts
in the widget content could access window.parent.localStorage and
exfiltrate the JWT.

sandbox="allow-scripts" without allow-same-origin sandboxes the widget
into a unique origin: inline scripts (clock, RSS, weather widgets)
continue to work, but parent-origin access and same-origin requests are
blocked. Verified via Playwright probe against all 6 widget types in the
dev DB (clock, rss, social, text, weather, webpage): each renders
correctly under the new sandbox and contentDocument access from the
parent is blocked (opaque-origin enforcement working). Admin preview
unchanged in appearance; player display unchanged.

Webpage widget (server/routes/widgets.js) sandbox tightening (drop
allow-same-origin) is a separate forthcoming commit - needs test against
real embed URLs since some sites rely on same-origin behavior. The
sandbox-attribute intersection rule means today's outer-iframe sandbox
will cascade and strip allow-same-origin from the webpage widget's inner
iframe too; accepted as a narrow cosmetic regression (cookies/localStorage
stripped for embedded sites) until the deliberate inner-iframe handling
ships.

SECURITY.md added with reporting process (GitHub Security Advisories
primary, support@bytetinker.net fallback) and scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 12:28:34 -05:00
ScreenTinker 159a36ed99 fix(workspaces): use APP_URL env var for invite-accept URL generation
Slice 1+3 (c4fbd2b) introduced PUBLIC_URL as the env var name for the
public-facing origin used to construct invite-accept URLs. The README
has long documented APP_URL as the canonical name for this concept
(used for Stripe callbacks in the existing codebase). The new code
should have read APP_URL from the start; PUBLIC_URL was unintentional
naming drift.

Caught during prod-deploy survey on 2026-05-17: APP_URL was set on the
production systemd unit and documented in the README, but read by no
code path on origin/main. PUBLIC_URL was read by slice-1 code but set
nowhere. The bug was masked in 99% of cases by the request-derived
fallback (${req.protocol}://${req.get('host')}) which produces the
correct URL when invites are triggered from browsers behind Cloudflare.
It would have manifested for any future non-browser-triggered invite
path.

README updated to note APP_URL covers both Stripe callbacks and
invite-accept URL generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:26:07 -05:00
ScreenTinker caa9fd0f40 feat(workspaces): mutation UI for members (slice 2B)
Completes P2 user-management. Adds the full admin surface for managing
workspace membership: invite modal, role change, member remove, cancel
pending invite. All admin-gated client-side via can_admin from /me,
server-gated via canAdminWorkspace.

Component additions:
- NEW workspace-members-invite-modal.js (~115 LOC). Mirrors
  workspace-rename-modal.js pattern (imperative open + listeners + close
  + esc/click-outside/enter). Two key differences: onSuccess callback
  instead of window.location.reload (allows targeted re-render of
  pending-invites section), and mapError callback so the parent's
  mapMutationError is the single regex-to-i18n source of truth (instead
  of duplicating in the modal).
- workspace-members.js: header invite button (can_admin gated), per-row
  affordances (role select + remove on direct members, cancel on invited
  rows, none on via_org rows), exported mapMutationError mapper,
  re-render on both success AND error for role-select to resync state
  when the server rejects.
- 4 api.js helpers (inviteWorkspaceMember, cancelWorkspaceInvite,
  updateWorkspaceMemberRole, removeWorkspaceMember).
- 24 i18n keys under members.modal.*, members.button.*,
  members.confirm.*, members.error.*, members.success.*
- CSS for .member-actions family (action buttons + role select + hover
  states).

UX decisions:
- Direct-member rows: role <select> replaces role text in same column;
  remove button right of detail
- via_org rows: no actions cell (server would 403; UI respects boundary)
- Invited rows: cancel button only (handoff rule was over-broad -
  cancel-invite IS a valid mutation on invited rows, refined during 2B
  survey)
- Role select fires on change, no Save button (matches teams.js pattern;
  mitigations for accidental clicks noted in handoff if reports come in)
- Mutations re-fetch + re-render rather than optimistic updates -
  simpler, no state-drift bugs, endpoints respond fast
- /invites endpoint skipped entirely when !can_admin (saves a request;
  server still enforces)

Verification: 21/21 Playwright assertions PASS across 6 cases (invite
happy path, invite collision, role change, remove member, last-admin
block, cancel invite). Test infrastructure stashed at
~/Documents/screentinker-2b-playwright-2026-05.py.

Closes P2 (user-management feature). Slice 1+3 backend landed c4fbd2b,
2A read-only view landed 8db171d, 2C accept-invite handler landed
399af54, 2B mutation UI landed here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:45:34 -05:00
ScreenTinker 399af54839 feat(workspaces): accept-invite URL handler (slice 2C) + email URL path fix
Slice 2C: hash route #/accept-invite/{id} with full flow support across
all six auth entry points (login/register/Google/Microsoft/support/setup)
via app-boot consumer pattern rather than per-handler hooks. Stash
mechanism uses localStorage with timestamp + staleness check
(INVITE_EXPIRY_DAYS_FRONTEND = 7, mirrors backend default). On success:
switch workspace, reload, show toast post-reload via scoped
pending_invite_toast key. On error: showToast directly, no reload.
Non-reentrant guard prevents double-consume across the synthetic
hashchange that fires before reload completes.

Two bugs surfaced during Playwright-driven verification (slice 1 left
two latent issues that only manifested when the full accept-invite
flow ran end-to-end):

1. Email URL path: workspaces.js constructed
   ${publicBase}/#/accept-invite/X which lands on the marketing landing
   page (the SPA is at /app). Fixed to use
   ${publicBase}/app#/accept-invite/X. Any invite email sent before
   this fix would have produced an unfollowable link.

2. Synchronous hashchange race: location.hash = '#/' followed by
   reload() fires hashchange BEFORE the reload unloads the page. The
   intermediate route() call would consume the toast key against a DOM
   about to be destroyed, so the post-reload page had no toast. Fixed
   with history.replaceState which mutates hash without firing
   hashchange.

Files:
- server/routes/workspaces.js (+4/-1, /app path fix + comment)
- frontend/js/api.js (+3 LOC, acceptInvite helper)
- frontend/js/app.js (+154 LOC, accept-invite plumbing)
- frontend/js/i18n/en.js (+9 LOC, accept.* keys)

Browser verification: 11/11 assertions PASS via Playwright suite
covering all 5 D-cases (unauthed flow, authed direct, wrong account,
stale stash, already-member). Script stashed at
~/Documents/screentinker-2c-playwright-2026-05.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:50:23 -05:00
ScreenTinker 8db171d979 feat(workspaces): members page read-only view (slice 2A)
Adds the workspace members page at #/workspace/:id/members.
Read-only listing only - mutations land in slice 2B,
accept-invite URL handler lands in slice 2C.

Three sections render based on access path:
- Members: direct workspace_members rows with role + join date
- Organization access: org_owner/org_admin who reach this
  workspace via org-level access (via_org=true). 75% opacity
  + italic "via organization" label to distinguish from direct
  membership. Section hidden if empty.
- Pending invites: workspace_invites rows (admin-only -
  section silently absent for non-admins via 403-suppress)

Switcher dropdown adds a "members" icon next to the rename
pencil, gated on can_admin (same predicate). Icon visible on
hover, mirrors the existing pencil pattern.

24 i18n keys added under members.* (read-only set; mutation
keys land in 2B).

Backend coverage from c4fbd2b unchanged; pre-flight curl
verification (13/13 cases) confirmed all 7 endpoints work as
documented before slice 2 first-exercised the four previously
untested ones (GET /invites, DELETE /invites/:id, PUT
/members/:userId, DELETE /members/:userId).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:00:51 -05:00
ScreenTinker c4fbd2ba5c feat(workspaces): invite/accept-invite backend (slice 1+3)
Slice 1 + 3 of the user-management feature from the May 12 plan.
Backend-only - no UI yet (slice 2 ships separately). Backend +
accept-handler together so the email accept link is functional
from day one without a half-state.

Endpoints added:
- GET    /api/workspaces/:id/members     (any member; via_org=true
                                          for org-level entries,
                                          read-only from ws context)
- GET    /api/workspaces/:id/invites     (workspace_admin)
- POST   /api/workspaces/:id/invites     (workspace_admin)
- DELETE /api/workspaces/:id/invites/:inviteId (workspace_admin)
- PUT    /api/workspaces/:id/members/:userId   (workspace_admin)
- DELETE /api/workspaces/:id/members/:userId   (workspace_admin)
- POST   /api/auth/accept-invite/:inviteId     (requireAuth +
                                                case-insensitive
                                                email match)

Permission gating:
- canAdminWorkspace (existing) for admin-gated endpoints
- canAccessWorkspace (new helper in lib/permissions.js) for the
  members read endpoint - mirrors canAdminWorkspace shape but
  admits any workspace_members role plus org/platform paths

Security additions vs the original plan:
- Transaction-bounded collision check on POST /invites closes the
  TOCTOU race between simultaneous duplicate POSTs (no UNIQUE
  constraint on workspace_invites(workspace_id, email))
- Per-(inviter, workspace), hour-window rate limit on POST /invites
  to prevent abuse / cost runaway. Env-configurable via
  INVITE_RATE_LIMIT_PER_HOUR with conservative 50/hour default.
  429 response is generic - does not echo the configured value.
- Invite expiry env-configurable via INVITE_EXPIRY_DAYS (default 7)
- PUBLIC_URL env var (optional) pins the accept-URL origin in prod;
  falls back to request-derived for local dev

Rollback rule on email send: only graph_error (real send attempt
failed at Graph) deletes the row and returns 502. not_configured
and dev_restricted are intentional non-sends - keep the row, count
against rate limit, allow local accept-invite testing to proceed.

Other safety blocks:
- Cannot demote/remove the last workspace_admin (409)
- Cannot remove the parent-org's org_owner via workspace path (403)
- Accept-invite is idempotent if user already a member
- Expired invites delete-on-read and return 410
- Wrong-account accept returns 403 without touching the invite

Expired-invite cleanup added to services/heartbeat.js mirroring
the team_invites sweep pattern.

Verification: 9-case curl-driven E2E against the dev DB fixture
(switcher-test + invitee-existing + invitee-new mid-flow register).
All 9 pass: create / collision-409 / second-create / rate-limit-429 /
existing-user-accept / register-then-accept / wrong-account-403 /
expired-410 / viewer-cannot-invite-403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:19:59 -05:00
ScreenTinker d2a3bdfd15 fix(auth/me): broaden non-admin accessible_workspaces to include org_owner/org_admin paths
The non-admin branch of /me's accessible_workspaces query drove
from workspace_members, so users with org_owner or org_admin on
an organization but no direct workspace_members row were missing
those workspaces from their /me response - and therefore from the
switcher dropdown. Mirrors the access logic in
accessibleWorkspaceIds() (lib/tenancy.js) while keeping the
full-row SELECT shape /me needs.

Verified end-to-end with switcher-test@local.test acting as
org_owner of Acme Studios with no workspace_members row on
Studio B - Studio B now appears in /me's accessible_workspaces
with workspace_role: null, can_admin: true.

Also updates the stale TODO comment in tenancy.js that flagged
this exact gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:50:37 -05:00
ScreenTinker 3294525f4c fix(socket): prefer WebSocket transport for dashboard socket
Mirrors the player-side fix in 1aee4f2 - skips the polling->WS
upgrade dance that was causing the dashboard socket to flicker
when Apply burst its fetch traffic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:15:18 -05:00
ScreenTinker 2725ea9152 docs(privacy): disclose error and diagnostic telemetry from players
Companion to 19f434d. The new player_debug_logs sink collects four
data categories not previously enumerated in the privacy policy:
browser user-agent, error/stack-trace data, recent player log entries
(which can include filenames of content being played), and screen/
viewport dimensions. New section 2.5 documents what's collected, why,
and the rolling-buffer retention model (10k entries, oldest pruned
on insert).

Section 5 (Self-Hosted Deployments) clarifies that the telemetry is
collected by the self-hoster's own server, not transmitted to us, and
points at the PLAYER_DEBUG_REPORTING=off kill switch for self-hosters
who prefer no collection at all.

Section 11 retention list gains a row for the rolling-buffer model.

"Last updated" bumped to May 15, 2026.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:31:21 -05:00
ScreenTinker 19f434d05a Add player debug overlay and server-side error telemetry sink
Smart TVs (Tizen, WebOS, Fire TV, Bravia) have no accessible browser
devtools, so when the player misbehaves on those platforms we previously
had zero visibility. This adds two paths to fix that:

- Visible debug overlay rendered on the TV screen for phone-photo capture
- Automatic server-side telemetry sink for hands-off error reporting

Client side (server/player/):
- Inline ES5 error trap as first script in index.html captures errors
  even from parse-time failures in later scripts. Captures into
  window.__debugLog with 200-entry cap.
- debug-overlay.js renders a fixed-position overlay covering the top 40%
  of the screen. Activates via ?debug=1, d-e-b-u-g key sequence, Samsung
  red button (keyCode 403), or smart-TV UA + ?autodebug=1. Freeze toggle
  (F key or Samsung green) with visible FROZEN badge for phone capture.
  pointer-events: none so touches pass through to the player underneath.
- Reporter machinery posts captured errors to /api/player-debug with
  5-second debounce batching, sendBeacon on unload (with payload size
  capping to stay under 64KB), 5-minute backoff after 429 responses.
  UA-gated: smart-TV allow-list first (handles Tizen-with-Chrome/108),
  modern-desktop deny-list second, default-report for unknown UAs.
- Two-pass djb2 fingerprint (16 hex chars) per error for future grouping.
- Absolute script src (/player/debug-overlay.js) so the script loads
  regardless of trailing-slash on the player URL.

Server side:
- New player_debug_logs table (10000-row FIFO cap, indexed on
  fingerprint + created_at). Schema in schema.sql, idempotent via
  CREATE TABLE IF NOT EXISTS.
- POST /api/player-debug unauthenticated (so unpaired players can also
  report), rate-limited 10/min/IP, per-field length caps to prevent abuse.
- Dynamic /player HTML route injects window.__playerConfig.debugReporting
  based on PLAYER_DEBUG_REPORTING env var (defaults on; =off suppresses
  all client telemetry traffic). Other player assets still served static.
- Admin routes (requireAuth + requireSuperAdmin):
  GET /api/player-debug/list with pagination and filters
  GET /api/player-debug/summary for UA family counts
  DELETE /api/player-debug/older-than for manual purge

Admin view (#/admin/player-debug):
- UA family summary at top (Tizen/WebOS/Fire TV/Bravia/Edge/Chrome/etc)
- Filter row: UA contains, date range, has-error checkbox
- Paginated table with expand-row JSON viewer for error_data and context
- device_id labeled (self-reported) since field is unauthenticated input
- Manual delete-older-than button with confirmation dialog

Verified end-to-end with Playwright + Chromium (17/17 checks pass) plus
manual real-browser verification including UA-spoofed Tizen flow landing
rows in the admin view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:20:42 -05:00
ScreenTinker 12fe0e43eb fix(zones): frontend assignment-flow picker + missed devices.js zone_id projection
Follow-up to 73f41c3 (server-side zone_id wiring). With this commit
the zone feature is verified working end-to-end: dashboard zone
picker renders correctly, zone_id saves and persists, the per-row
zone dropdown reflects the saved zone after reload, and a live
player run with computed-style inspection confirmed zone divs and
video elements size correctly within their geometry.

Frontend (device-detail.js, en.js):
- Add-content modal: zone picker slot now renders in all four states
  (has_zones / no_layout / fetch_failed / empty_layout) instead of
  silently vanishing when zones.length === 0. Informational rows
  match form-group styling and tell the user which control to use
  next. Closes the gate-4 symptom where 38-of-42 devices (no layout
  assigned) silently dropped zone_id on every assignment.
- Both /api/layouts/:id fetches (add modal, edit-path) now have
  !res.ok throw guards and surface failures via console.warn instead
  of swallowing them. The add modal additionally exposes the failure
  state to the user via the fetch_failed info row.
- Edit-path zone dropdown: replaced brittle DOM-scraping (reading
  the i18n label text and matching z.id.slice(0,8) against rendered
  meta HTML) with a data-current-zone-id attribute stashed at row
  render from a.zone_id. Removes the i18n-format coupling and gives
  exact UUID match.
- 3 new i18n keys in en.js (other locales fall back).

Server (devices.js):
- The GET /api/devices/:id assignments query had its own ad-hoc
  SELECT projection that was missed during the 73f41c3 site survey.
  Without pi.zone_id in this projection, loadDevice() got assignments
  without zone_id and the edit-path dropdown displayed "No zone"
  after every save+reload even though the DB had the correct value.
  One-line fix: add pi.zone_id, mirroring the ITEM_SELECT change in
  routes/assignments.js. Listed as the 8th site that 73f41c3's
  original survey missed; this commit closes it.

Verification:
- JS parse + en.js ESM load + server module load all clean.
- Live SQL probe: GET /api/devices/:id projection now returns zone_id
  for the test rows (id=31 zone_id=z-sh-1, id=54 zone_id=z-sh-2).
- Browser test by hand: zone picker renders per state, zone_id
  persists, reload shows saved zone, computed styles on rendered
  .zone divs match expected geometry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:26:58 -05:00
ScreenTinker 73f41c3288 fix(zone-id): restore zone-aware playlist_items wiring (issue #3 follow-up)
Phase 2 (assignments -> playlist_items) dropped zone_id during the
conversion: migrateAssignmentsToPlaylists INSERTed only (playlist_id,
content_id, widget_id, sort_order, duration_sec), and the new
playlist_items DDL omitted the zone_id column entirely. Every write
path on top of playlist_items inherited that omission - the
multi-zone layout assignment feature stopped working.

Frontend always sent zone_id correctly (device-detail.js:1015,1072
POST and PUT both include it; api.addAssignment and api.updateAssignment
forward the body verbatim). Server silently dropped it. The
assignments.js PUT route was the most direct evidence: it destructured
zone_id from req.body but never added it to the updates array.

Schema:
- schema.sql: add zone_id TEXT REFERENCES layout_zones(id) ON DELETE
  SET NULL to fresh-install DDL.
- database.js migrations[]: add idempotent ALTER TABLE for existing
  installs (the surrounding try/catch loop handles duplicate-column).

Backfill (new gated migration phase2_zone_id_backfill):
- Pre-migration snapshot copied to db/remote_display.pre-zone-id-
  backfill-<ts>.db (one-off for this migration; the general
  every-migration-snapshot framework is a separate concern, not built
  here).
- Best-effort UPDATE playlist_items.zone_id from surviving
  assignments rows via device.playlist_id + content_id/widget_id
  match, LIMIT 1 for the multi-match edge case.
- Regenerates published_snapshot for every published playlist so the
  JSON the player consumes carries zone_id going forward. Even with
  zero rows backfilled (the common case post-Phase-2 cleanup) this
  closes the snapshot-staleness gap.
- Stamps schema_migrations regardless so it won't re-run on next boot.
- On the live local DB: 0 playlist_items backfilled, 18
  published_snapshots regenerated. On the April 13 prod fixture
  (sandboxed copy): 0 backfilled, 7 regenerated. Expected and matches
  our pre-flight finding that assignments was effectively scrubbed of
  zone_id everywhere.

Route wiring (7 sites + 1 shared constant):
- assignments.js ITEM_SELECT: project pi.zone_id (read path so the
  frontend display at device-detail.js:500 surfaces the value).
- assignments.js POST INSERT: include zone_id column + value.
- assignments.js PUT: actually use the already-destructured zone_id
  in the updates allow-list. Treats undefined as "no change" so a PUT
  that omits zone_id leaves the existing value intact; any explicit
  value (including null) is written.
- assignments.js copy-to INSERT: preserve a.zone_id during
  device-to-device playlist copy.
- playlists.js buildSnapshotItems: project pi.zone_id so the snapshot
  JSON carries it. This is what the player's renderZones loop reads
  (player/index.html:1338 matches a.zone_id === zone.id).
- playlists.js discard-revert INSERT: restore zone_id from snapshot
  item on revert.

Out of scope (verified safe by SQL semantics + UI inspection):
- playlists.js POST item-add and PUT item-update in the playlist-detail
  surface: the UI there doesn't expose zone editing, and their SQL
  leaves zone_id NULL on insert / untouched on update. No regression.
- Other playlists.js SELECT projections (lines 141, 190, 240, 265, 334,
  379, 419) all use SELECT pi.* and auto-pick zone_id once the column
  exists.
- Kiosk-page assign at device-detail.js:1027 doesn't send zone_id;
  separate pre-existing gap, not part of this regression.

Tests (all local, no push, no prod deploy):
- Migration boot on live local DB: clean, idempotent (second boot
  skips the gated function).
- Migration boot on April 13 prod fixture (sandboxed copy at
  /tmp/zone-fix-fixtures/test-run.db): cleanly runs the full migration
  stack (multi-tenancy + 5 other phases the fixture predated) then
  the new zone_id backfill. Live local DB untouched.
- 8 SQL-level route behavior tests pass: INSERT stores zone_id, PUT
  changes/clears zone_id, ITEM_SELECT and buildSnapshotItems
  projections include zone_id, copy-to preserves, discard-revert
  restores from snapshot JSON, undefined zone_id in PUT leaves
  existing value intact.

Not verified: end-to-end multi-zone playback on a real device. The
SQL + snapshot JSON layer is correct (player consumes
playlist.find(a => a.zone_id === zone.id) and now gets the right
zone_id back from the snapshot); confirming render-to-correct-zone
on actual hardware is the next step before prod deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:20:44 -05:00
ScreenTinker cdd29d5e3b Merge pull request #6 from ChrisChrome: web player auto-connect
Adds a 5-second countdown on the player's Connect button when the
device is unpaired. Auto-clicks at 0 unless the user interacts with
the serverUrl field. Useful for headless kiosks where clicking is
painful. Includes a follow-up race-condition fix so the timer can't
double-fire if Connect is clicked manually during the countdown.
2026-05-14 16:12:58 -05:00
Christopher Cookman f6ef75549b Fix possible race condition in player auto-connect 2026-05-14 14:54:17 -06:00
Christopher Cookman 98e742c612
Merge branch 'screentinker:main' into main 2026-05-14 13:46:40 -06:00
Christopher Cookman d5e4e4d927 Feat: Web player auto connect
Add a simple 5 second countdown to the web player to get a code without interacting (for systems where interaction is a hassle, or impossible)
2026-05-14 13:46:19 -06:00
ScreenTinker 8439f2bf18 fix(landing): replace broken Custom pricing card with enterprise contact form
The "Custom" tier on the public pricing page was misrendering as a
better-than-Free tier: headline "Custom", price "Free", "Unlimited
devices/storage", "Get Started" button. Root cause is in DB data,
not markup - the 'enterprise' plan row has price_monthly=0 and
max_devices/storage=-1, and the dynamic render in landing.html maps
those to "Free" + "Unlimited" with the wrong CTA.

Fix: filter the 'enterprise' plan out of the public landing render
(client-side, in landing.html only) and replace it with a hardcoded
Enterprise / Custom marketing card whose Contact Us button opens a
new lead-capture modal.

The DB row itself stays - it is actively used elsewhere:
- auth.js: first user in SELF_HOSTED=true mode is assigned to it
- settings.js: white-label feature is gated on enterprise plan
- 1 user (the dev account) is currently assigned to it
- /api/subscription/plans is also consumed by billing.js, settings.js,
  admin.js (logged-in surfaces); they keep getting the full plan list.
The filter is scoped to landing.html's render only.

The in-app billing page renders the same plan with the same cosmetic
bug; that's a logged-in admin surface, out of scope for this commit.

Other 4 cards (Free, Starter, Pro, Business) unchanged.

Frontend (landing.html):
- Filter 'enterprise' from public render
- Hardcoded Enterprise / Custom card. Uses .price class with "Let's
  talk" + empty .yearly spacer to match Free card's vertical baseline
  so the feature list aligns with the paid cards' baselines.
- Modal markup, CSS (mirrored from frontend/css/main.css conventions
  since landing.html doesn't import main.css), and inline JS for
  open/close/submit/escape/background-click.
- Honeypot field: hidden 'fax_number' input (off-screen + aria-hidden
  + tabindex=-1). Picked over the obvious 'website' name to catch
  mid-tier bots that explicitly skip the well-known honeypot names.

Backend (new server/routes/contact.js):
- POST /api/contact/enterprise, public (unauthenticated)
- Rate limited 5/min/IP+path via the existing rateLimit middleware
- Honeypot check: populated fax_number returns 200 silently, no email
- Server-side validation: required fields, email format, screens
  1-100000, multi_tenant in {single,multi}, hosting in {hosted,self,
  unsure}. Length caps prevent textarea-bomb abuse.
- Sends via existing services/email.js (Microsoft Graph) to
  dan@bytetinker.net from the support@screentinker.com Graph sender.
- Log lines: "[contact] enterprise inquiry from EMAIL (COMPANY)
  delivered" or "[contact] honeypot triggered from IP; dropping".

Wired in server.js alongside other public routes (before requireAuth).

Build-time tests passed locally:
- Module loads, server boots clean
- Validation: missing fields, bad email, bad multi_tenant, bad
  hosting, screens out of range - all return 400 with the right
  error message
- Honeypot: populated fax_number returns 200 success, no email sent,
  log line confirms drop
- Rate limit: kicks in at 6th request within a minute as expected
- Real end-to-end send: one test submission delivered to
  dan@bytetinker.net via Graph (subject "[ScreenTinker] Enterprise
  inquiry: ScreenTinker Build Verification", body formatted with all
  fields). GRAPH_DEV_RESTRICT_TO was temporarily widened to include
  the recipient for the test and restored to dw5304@gmail.com
  immediately after.
- Card render order verified against live API: Free (outline,
  Get Started) | Starter | Pro (featured, Most Popular badge) |
  Business | Enterprise / Custom (Contact Us -> modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:52:24 -05:00
ScreenTinker f5ca26ae2d fix(socket): offline debounce + truthful single-device command feedback
Two dashboard-accuracy improvements for issue #3.

Disconnect debounce (5s):
- Brief transient flaps (Engine.IO ping miss, eviction-then-reconnect,
  Wi-Fi blip) no longer immediately flip the device to offline in the
  dashboard. Disconnect handler now defers the offline transition;
  register handlers cancel the pending timer if reconnect lands in
  window.
- Existing stale-disconnect guard kept as fast-path for the eviction
  case (no timer scheduled at all when the active heartbeat conn is
  already a different socket).
- Re-check at timer fire compares socketIds: aborts only if a
  GENUINELY DIFFERENT socket reclaimed the device. Just the closing
  socket's own (not-yet-cleaned-up) entry is treated as stale and
  proceeds with offline transition.
- Server-restart mid-grace is handled by the heartbeat checker safety
  net (existing component): any 'online' row with last_heartbeat
  older than heartbeatTimeout gets marked offline on next sweep.

Truthful single-device command feedback:
- dashboard:device-command handler now checks deviceNs.adapter.rooms
  for an active socket before emitting (matches the group-command
  route's pattern).
- If room is empty, falls through to commandQueue.queueCommand (lazy
  require - if commit C is reverted, MODULE_NOT_FOUND is cached and
  every subsequent call gets consistent queued=false behavior).
- Returns three-state ack to caller: { delivered, queued, reason }.
- Server log line was misleading - now logs 'Command delivered to
  device X' vs 'Command for offline device X (queued=true/false)'.

Frontend:
- sendCommand() takes optional callback. Without one, fires-and-forgets
  (no behavior change for non-wired callers). With one, uses Socket.IO
  .timeout(5000).emit so the callback always fires (ack or no_ack).
- Six device-detail command buttons wired to three-state toasts:
  reboot, shutdown, screen_off, screen_on, launch, update.
  - delivered: green/success toast (existing localized message)
  - queued: amber/warning toast (new generic message)
  - no_ack: red/error toast
  - fallback: red/error toast
- Two callers intentionally left fire-and-forget:
  - window._sendCmd (generic remote-overlay keypress/touch helper)
  - enable_system_capture (has its own visual state machine; out of
    scope for this commit)

Three new i18n keys (en.js only; other locales follow later):
- device.toast.command_queued
- device.toast.command_undeliverable
- device.toast.command_no_ack

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:11:40 -05:00
ScreenTinker 742d8c4b09 feat(socket): delivery queue for offline-device emits
Short-lived per-device queue covers the TV-flap window (issue #3):
when a device is mid-reconnect, prior code emitted to an empty room
and the event vanished. Now playlist-updates and commands targeting
an offline device are queued and flushed in order on the next
device:register for that device_id.

server/lib/command-queue.js (new):
- pendingPlaylistUpdate: per-device marker (rebuild via builder on
  flush -> always fresh DB state, no stale snapshots)
- pendingCommands: per-device Map<type, payload> with last-of-type
  dedup (most recent screen_off wins)
- TTL via COMMAND_QUEUE_TTL_MS env (default 30000)
- Active sweep every 30s prunes expired entries

Memory bounds: ~6 entries per device worst case (1 playlist marker
+ 5 command types), unref'd sweep timer.

Wired emit sites (8 total; the four direct socket.emit calls in
deviceSocket register handlers are intentionally NOT queued because
the socket is alive by definition at those points):
- server/routes/video-walls.js   (pushWallPayloadToDevice)
- server/routes/device-groups.js (pushPlaylistToDevice)
- server/routes/content.js       (content-delete fan-out)
- server/routes/playlists.js     (pushToDevices + assign)
- server/services/scheduler.js   (scheduled rotations)
- server/ws/deviceSocket.js x2   (wall leader reclaim/reassign)

server/ws/deviceSocket.js register paths now call flushQueue after
heartbeat.registerConnection + socket.join. Existing
socket.emit('device:playlist-update', ...) lines kept - they send
the initial state on register; the flush replays any queued events.
Player's handlePlaylistUpdate fingerprint check dedupes the
overlap.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:06:43 -05:00
ScreenTinker 3da49ec79c chore(config): env-configurable heartbeat timing
Make HEARTBEAT_INTERVAL and HEARTBEAT_TIMEOUT env-tunable so
self-hosters with slow/jittery networks don't have to edit
config.js (issue #3 reporter did exactly this to confirm the
diagnosis). Defaults unchanged at 10000ms / 45000ms so existing
deployments keep current behavior.

Same parseInt(env) || default pattern as PORT/HTTPS_PORT/PING_*.
README env table extended.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:03:02 -05:00
ScreenTinker 1aee4f2d5b fix(socket): raise Engine.IO ping/pong + prefer WebSocket transport
Connection-stability layer for issue #3. LG webOS WebKit (and other
TV-grade clients) miss Engine.IO pongs under decode load with the
Socket.IO defaults of 25s ping / 20s timeout, causing spurious
transport drops and a connect/reconnect/evict/disconnect loop on
the device. Default polling-first transport adds another fragility
layer via the polling->WebSocket upgrade dance.

- pingInterval / pingTimeout default to 30000 / 30000 (worst-case
  dead-socket detection 60s, up from ~45s). Both env-configurable
  via PING_INTERVAL / PING_TIMEOUT.
- Player Socket.IO client: transports: ['websocket', 'polling'].
  Tries WebSocket first; falls back to polling on the same connect
  attempt if WebSocket fails. Polling fallback preserved for
  firewall-restricted networks.

App-level heartbeat checker is unchanged and remains the safety net
for clients that miss the transport-level ping/pong window.

Tradeoffs documented in inline comments. README env table extended
with PING_INTERVAL and PING_TIMEOUT rows.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:02:34 -05:00
ScreenTinker c4ac81c7a6 chore(discord): update Discord invite link
Old invite replaced with current permanent invite across README,
landing page, and anywhere else it appeared.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:26:20 -05:00
ScreenTinker 1e23335356 fix(player): graceful handling when displayed content is removed
Deleting a content asset that was actively displayed on screens
caused affected players to go black and never recover; deleting an
actively-playing video also failed to stop playback (audio kept
going). Root cause: handlePlaylistUpdate never tore down the current
media element and could drive currentIndex to NaN when a late
onended fired during the playlist swap.

- Add teardownCurrentMedia() - pause, clear src, .load() to actually
  release the decoder and kill audio; null event handlers to prevent
  late onended races
- handlePlaylistUpdate: preserve continuity - if the playing item
  survives the update keep it playing, otherwise walk forward from
  the old position to the next surviving item; empty playlist tears
  down to waiting state
- Guard playCurrentItem against empty playlist / non-finite index
- Remove dead device:content-delete socket handler (never emitted)

Resolves #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:17:40 -05:00
ScreenTinker 3dfec5d2f9 feat(config): DISABLE_HOMEPAGE env var to redirect / to the app
Self-hosters running internal-only deployments don't need the
marketing homepage. With DISABLE_HOMEPAGE=true, requests to /
302-redirect to /app instead of serving the landing page.
Unset/false preserves current behavior.

Requested via discord feedback.
2026-05-14 12:03:29 -05:00
ScreenTinker 4b2a5c51ea docs(readme): comprehensive review - multi-tenancy, current features, tech stack, deployment, contribution
The repo has been shipping multiple features ahead of the README (12+
commits today alone). This is a catch-up pass to bring the docs current.

Key additions / updates:
- Multi-tenancy architecture (orgs > workspaces > members + roles)
- Auto-migration on boot
- Teams currently consolidated into workspace_members
- Tech stack reference (Node 20.6+, msal-node, etc.)
- Deployment env vars (full reference table)
- Local dev setup with .env approach
- Contribution/Discord/issue reporting

No code changes - docs only.
2026-05-12 18:57:41 -05:00
ScreenTinker f4d2a0330b chore(email): log successful sends for observability
Previously sendEmail() only logged on error/suppression paths; success
was silent. After prod deploy of c71c401 it was unclear whether the
first alert tick had actually delivered email or not - the answer was
yes but had to be derived from 'no error log + recipient query showed
matching device'. Add a log line on success so future observability
doesn't require detective work.
2026-05-12 18:34:19 -05:00
ScreenTinker dddc48440b docs(readme): document Microsoft Graph email setup + dev restrict + spam protections
Replaces the stub EMAIL_WEBHOOK_URL row with the real 5-variable
GRAPH_* config table, Azure AD app registration steps (single-tenant
+ Mail.Send application permission + admin consent), the local-dev
stdout-fallback behavior when unconfigured, the optional
GRAPH_DEV_RESTRICT_TO allow-list for safe development against fresh
prod DB clones, and a brief enumeration of the alert spam protections
(2h dedup, 24h long-offline cutoff, sequential send pattern, per-user
email_alerts opt-out).

Pairs with c71c401 which shipped the implementation.
2026-05-12 18:34:08 -05:00
ScreenTinker c71c4016ca feat(email): Microsoft Graph send + alert spam protection + preferences UI
Replaces the unused EMAIL_WEBHOOK_URL stub with a real Microsoft Graph
Mail.Send pipeline via @azure/msal-node client-credentials flow. Prior
state on prod: every alert email was logged to journalctl and never
sent (21 fallback log lines per hour for the chronic-offline devices).

Four coordinated changes shipped as one commit since they're all part
of making email delivery actually work responsibly:

1. services/email.js (NEW): Graph send via plain HTTPS (no SDK), in-memory
   MSAL token cache (refresh 60s pre-expiry), graceful stdout fallback
   when GRAPH_* env vars absent. Drop-in replacement for the old webhook.

2. services/alerts.js refactored: sequential await around sendEmail (was
   parallel fire-and-forget; first run hit Graph's MailboxConcurrency 429
   ApplicationThrottled on a 30-device backlog). Sequential at ~250ms per
   send takes 5-8s for the full backlog, well within the 60s tick. Also:
   24h long-offline cutoff to stop nagging about chronic-offline devices
   (the 20,000+ minute ones); 2-hour dedup window (was 1h) via a generic
   shouldSendAlert(type, id, windowMs) helper that future alert types
   (payment_failed, plan_limit_hit, etc.) can reuse.

3. Preferences UI: single checkbox in settings.js Account section bound
   to users.email_alerts. Saved via the existing Save Profile button. PUT
   /api/auth/me extended to accept email_alerts. requireAuth middleware
   SELECT now includes email_alerts so it propagates via req.user.

4. Dev safety net: GRAPH_DEV_RESTRICT_TO env var as an allow-list. When
   set, only listed recipients reach Graph; everyone else is suppressed
   with a log line. Prevents local dev (which often runs against fresh
   prod DB copies) from accidentally emailing real prod users. UNSET on
   prod systemd unit so production fans out normally.

Also: package.json scripts use --env-file-if-exists=.env so local dev
picks up .env automatically (Node 20.6+ built-in, no dotenv dep). Prod
runs via systemd ExecStart and is unaffected. server/.gitignore added
to keep .env out of git.

Smoke verified end-to-end:
- Sequential send pattern verified (a prior parallel-send tick had hit
  Graph's MailboxConcurrency 429 on 30 simultaneous sends; sequential
  at ~250ms each completes the same backlog without throttling)
- 24h cutoff silenced 20/21 prod devices on the next tick
- Dev restrict suppressed the 1 within-24h send
- User-preference toggle flipped via UI -> DB -> alert path silently
  continued before reaching even the suppression log
2026-05-12 18:16:40 -05:00
ScreenTinker f115cb454f style(switcher): widen dropdown, tighten rows, prevent org-line wrap
Visual polish to match the new device-count info in ce332ea. The
sidebar-constrained 188px dropdown was too narrow once a second
info chunk ('. N devices') joined the org name on the muted subtitle
line - long names like 'BRASA SALA\'s organization . 2 devices'
wrapped, doubling row height for half the dropdown.

Width: was left:0 + right:0 (= sidebar content width 188px). Now
left:0 + min-width:280px + max-width:360px. Detaches from the
sidebar (which is z-indexed) and extends into the main content area;
the max bound prevents indefinite sprawl on pathological org names.

Row height: padding 10px 12px -> 8px 12px; ws-org margin-top 2px -> 1px.
~58px per row -> ~46px. Less density-heavy at the platform_admin scale
(37 rows visible).

Menu padding: 4px 0 added on the panel so the first/last rows don't
sit flush against the panel border (fixes the 'first row clipped'
visual the tighter rows would otherwise still show).

Max-height: 320px -> 360px. Modest bump now that rows are shorter -
shows ~7 rows at once vs ~5 before.

.ws-org gains white-space:nowrap + overflow:hidden + text-overflow:ellipsis
so the org+count line truncates instead of wrapping. The 360px max-width
sets the truncation threshold.
2026-05-12 14:04:56 -05:00
ScreenTinker ce332ead67 feat(switcher): per-workspace device count in dropdown rows
/me's accessible_workspaces query gains a device_count field via a
correlated subquery on workspaces.id - WHERE workspace_id = w.id
strictly excludes the unclaimed pair-pool (workspace_id IS NULL fails
equality). Added to both query branches (platform_admin LEFT JOIN and
regular INNER JOIN); microseconds per row at current scale (~37 rows
worst case), not optimizing.

Frontend appends the count to the muted org-name line with a middle-dot
separator: 'Acme Studios . 2 devices'. Singular/plural respected via the
existing tn() helper convention; 'No devices' for empty workspaces. New
formatResourceCount(n, keyBase, zeroKey) helper is generic so the same
shape can wire users/playlists/schedules counts later without refactor.

New i18n keys: switcher.devices_count_one, switcher.devices_count_other,
switcher.no_devices. Added to en.js only; other locales fall back to en
via the existing lookup chain (verified in i18n.js:19).

API smoke verified: switcher-test sees Studio A=2, Field Crew=2;
dw5304 (platform_admin) sees all 37 workspaces with their device counts
varying 0-4; single-workspace zero-device user (geoff.case) sees 0.
2026-05-12 14:04:21 -05:00
ScreenTinker 42966da973 feat(teams): temporarily disable Teams API while feature is redesigned
Teams in its pre-Workspaces form is being paused while the feature is
redesigned as a user-grouping primitive within the new Workspaces
architecture. The original Teams data model had no workspace-awareness
and was effectively non-functional after Phase 2.2 (every route migrated
away from team_id), but the UI remained reachable and allowed users to
accumulate orphan data while believing they were configuring access
control.

Hide the Teams sidebar nav entry to prevent new entries to the UI.
/api/teams now returns 503 Service Unavailable with a 'feature
redesign in progress' message. Existing teams/team_members/team_invites
table data is preserved indefinitely for forward migration to the
future teams design.

Bonus: requireAuth middleware fires before the catch-all so unauthenticated
callers see the standard 401 instead of the 503 redesign message - avoids
exposing the 'feature being redesigned' signal to unauthenticated probes
or fingerprint scanners.
2026-05-12 13:30:55 -05:00
ScreenTinker 766f02ae5d docs(upload): correct misleading defParamCharset comment
The previous comment claimed defParamCharset:'utf8' fixed multipart
filename header decoding. It doesn't - that option only fires for the
RFC 5987 encoded filename*=utf-8''... form, which clients rarely send.
The actual UTF-8 recovery happens in the storage.filename callback
(added in d679ca8) via Buffer.from(name,'latin1').toString('utf8').
The option is kept set for the rare RFC 5987 case but the comment no
longer overclaims what it does.
2026-05-12 11:57:54 -05:00
ScreenTinker d679ca8d14 fix(upload): re-decode multipart filename header from latin1 to utf8 in multer storage callback
busboy reads the Content-Disposition filename="..." header value as
latin1 by default - even with defParamCharset:'utf8' set, that option
only applies to RFC 5987 encoded filename*=... params, which most
clients (browsers, curl, programmatic HTTP) don't send. Modern clients
send raw UTF-8 bytes for non-ASCII filenames; busboy interprets those
bytes one-byte-per-char as latin1, producing a JS string like 'A-tilde
+ quarter-mark' for 'u-umlaut'. JS then re-encodes that string as UTF-8
on the way to SQLite, yielding 4 bytes (c3 83 c2 bc) for what should be
2 bytes (c3 bc). Classic double-encoding mojibake - shows up in the UI
as 'BegrA-tilde...' instead of 'Begru-umlaut...'.

Fix: in the multer filename callback, re-decode file.originalname from
latin1 to utf8 to recover the original byte sequence. Mutating
originalname here propagates to every route handler reading
req.file.originalname (POST /, PUT /:id/replace, and any future upload
route using the same middleware).

This is the actual visible-mojibake bug semetra22 reported. The prior
commit b677752 (NFC normalize in safeFilename) handles a separate but
related case (macOS NFD clients sending decomposed forms); both fixes
compose correctly - latin1->utf8 first restores the byte sequence,
then NFC normalize collapses NFD into composed form.

Smoke verified by sending raw UTF-8 multipart from a Node https client
(no shell escaping). NFC input 'Begru-umlaut-essungsscreens.jpg' with
bytes c3bc c39f arrives clean (was c383c2bc c383c29f before). NFD input
'u + combining diaeresis' arrives as composed NFC c3bc after both fixes.
2026-05-12 11:55:55 -05:00
ScreenTinker b67775283b fix(server): NFC normalize user-facing filenames in safeFilename
Single line change to safeFilename() in routes/content.js: add
.normalize('NFC') before sanitizeString. Covers all 4 user-facing
filename storage sites (POST /, POST /remote, POST /embed, PUT /:id
rename) since they all flow through safeFilename.

Fixes macOS NFD vs Linux NFC mismatch on filename storage that mangled
umlauts (ae/oe/ue/ss) in displayed filenames. macOS clients send
NFD-decomposed names (e.g. 'u' + combining diaeresis U+0308 instead of
the precomposed U+00FC); Linux + most renderers expect NFC. Without
this, names like 'Begruessungsscreens.jpg' arrive with the combining
char floating and display as mojibake.

Reported by semetra22 in Discord with extraordinarily good debugging
narrowing (rename works, upload doesn't = bug is in upload path).
Single-point fix at the convergence of all user-facing filename flows.

Existing NFD-mangled rows in DB not backfilled; users can re-upload or
rename to repair. Optional one-time UPDATE backfill captured as follow-up
in handoff doc.

Smoke verified by invoking safeFilename directly on NFD + NFC inputs of
'Begruessungsscreens.jpg' - both produce identical NFC-normalized bytes
(42656772c3bcc39f756e677373637265656e732e6a7067).
2026-05-12 11:51:34 -05:00
ScreenTinker 1e142d9644 fix(frontend): playlists.js thumbnail path uses API endpoint instead of legacy direct path
2 line substitutions in frontend/js/views/playlists.js: switches
/uploads/thumbnails/{filename} -> /api/content/{id}/thumbnail at both
the playlist editor render (line 293) and the Add-to-playlist content
picker (line 543). Brings playlist view inline with widgets.js,
content-library.js, and device-detail.js which already use the API
path.

Side benefit: thumbnails now go through the workspace-aware permission
check in content.js's /api/content/:id/thumbnail handler (checkContentRead)
instead of unauthenticated static file serve at /uploads/thumbnails/.

Reported by semetra22 in Discord ('All images retrieved via the API
display correctly, but in the playlists, the images are fetched
directly from /uploads/thumbnails/filename and do not display properly').
2026-05-12 11:44:30 -05:00
ScreenTinker fc29843035 feat(socket): Phase 2.3 workspace-scoped dashboard socket rooms + per-command permission gates. Dashboard namespace was previously a flat broadcast - every connected dashboard received every device's status/screenshot/playback events platform-wide (foreign device names + IPs included). Inbound socket commands gated by a legacy admin/superadmin role check that was dead code post-Phase-1 rename.
Fix: at connect, enumerate the user's accessible workspace_ids (direct workspace_members + org_owner/admin paths + platform_admin 'all') via new accessibleWorkspaceIds() helper in lib/tenancy.js; socket.join one room per workspace. All 12 dashboardNs.emit sites across deviceSocket / heartbeat / server.js / devices route / video-walls route now route via dashboardNs.to(workspaceRoom(...)).emit() with the workspace looked up from the relevant device or wall. New lib/socket-rooms.js holds the helpers and breaks a circular dependency (dashboardSocket already requires heartbeat, so heartbeat can't require dashboardSocket).

Inbound 6 commands rewired to canActOnDevice(socket, deviceId, tier): request-screenshot is read tier (workspace_viewer+); remote-touch/key/start/stop and device-command are write tier (workspace_editor+). Platform_admin and org_owner/admin always pass via actingAs. Legacy admin/superadmin branch dropped.

Lifecycle note: workspace-switch already calls window.location.reload (Phase 3 switcher), which forces a fresh socket with updated memberships - no per-emit re-evaluation needed.

Smoke tested with 3 simultaneous socket.io-client connections (switcher-test, swninja, dw5304 platform_admin) + direct canActOnDevice invocation for 6 user/device/tier combinations. All 9 outbound isolation cells and all 6 permission gates pass. Fixture mutation: switcher-test's Field Crew membership flipped from workspace_editor to workspace_viewer to exercise the read/write tier split in one login.
2026-05-12 11:34:24 -05:00
ScreenTinker 56da64d0cd feat(workspaces): rename via switcher dropdown - new PATCH /api/workspaces/:id route, per-row pencil affordance in switcher (visible only when caller can_admin), small rename modal with name + slug fields, validation (name <=80 chars, slug ^[a-z0-9]+(?:-[a-z0-9]+)*$ <=60 chars, blank slug -> NULL), 409 on per-org slug collision. Permission gating via new canAdminWorkspace(db, user, ws) helper in lib/permissions.js - reused-ready for future Phase 3 admin actions. /me query now joins organization_members to compute can_admin per accessible_workspaces entry. Drive-by fixes surfaced: (1) activityLogger method filter was missing PATCH, added; (2) routes that operate on a target workspace by URL param need to stamp req.workspaceId from the param so activityLogger captures the right tenant attribution - documented in the route. Smoke fixture: switcher-test@local.test is workspace_admin of Studio A and workspace_editor of Field Crew (no org_owner) so the can_admin true/false split is exercised in one login. 2026-05-12 11:06:55 -05:00
ScreenTinker 0c91390e56 fix(frontend): workspace switcher (Phase 3 MVP) + SW network-first migration + platform_admin accessible_workspaces expansion + static render CSS cleanup. The switcher adds a sidebar dropdown for users who are members of multiple workspaces, renders as static text with a 'Workspace' label for single-workspace users, and muted 'No workspace' for zero. Uses existing /api/auth/me's accessible_workspaces and POST /api/auth/switch-workspace endpoints. Platform admin / superadmin users now see all workspaces in accessible_workspaces (closing the known regression from 88d91b1) via a LEFT JOIN that preserves workspace_role semantics (null = acting-as, role string = direct member). No cap on the list - deliberate for now, revisit at 50+ workspaces. SW fix bumps rd-admin-v1 -> rd-admin-v2 and switches fetch strategy from cache-first to network-first so the server's existing Cache-Control: no-cache + ETag headers actually get respected; preserves offline fallback. Static render CSS drops the bordered-box chrome that was making single-workspace users think the static text was clickable. Includes test fixture user switcher-test@local.test (credentials in fixture SQL header). Surfaced by semetra22 / Discord report about 'screens jumbled up' post-migration; root cause was the missing workspace switcher UI making devices in non-active workspaces appear missing. 2026-05-12 10:55:09 -05:00
ScreenTinker bc445a0a7c fix(boot): auto-apply Phase 1 multi-tenancy migration on startup if not yet applied; refactor scripts/migrate-multitenancy.js to expose runMigration() with CLI wrapper preserved; pre-migration snapshot to db/remote_display.pre-migration-<timestamp>.db; belt-and-suspenders guards on migrateFolderWorkspaceIds + backfillActivityLogWorkspace so the inline backfills skip cleanly if workspaces table absent. Fixes startup crash on pre-multi-tenancy installs (semetra22 / Discord report) where 'npm start' after pulling latest hit migrateFolderWorkspaceIds and crashed with 'no such table: workspaces'. Self-hosters now get an automatic upgrade path without needing to run 'node scripts/migrate-multitenancy.js' manually. 2026-05-12 08:22:47 -05:00
ScreenTinker 92e26aafcb fix(server): mount activityLogger middleware before workspace routes so POST/PUT/DELETE actually get logged - pre-existing bug, the middleware was a no-op for every API route because route mounts came first in server.js (L305 routes vs L368 middleware). Zero double-log risk: the one inline logActivity caller at routes/auth.js:452 is on /api/auth which mounts before the new middleware position. activity_log row growth will pick up significantly going forward (pruneActivityLog 90-day retention already handles the bound). Surfaced by Phase 2.2 migration discipline. 2026-05-11 23:17:28 -05:00
ScreenTinker 88d91b10af activity_log: stop the bleeding - writer-leak fix on 3 sites (activityLogger middleware, alert service, login route) + one-time backfill of 548 NULL-workspace rows via device.workspace_id or workspace_members lookup; activity.js route migration deferred to its own slice tomorrow.
KNOWN REGRESSION (Phase 3 fix): platform_admin / superadmin no longer has cross-workspace 'see everything' view. Every route migrated tonight (2.2a-2.2m) deliberately removed the role-based bypass per design doc - cross-workspace visibility will come via dedicated admin endpoints in Phase 3, not magic role bypasses. Until Phase 3 ships, platform admins must switch-workspace to see other workspaces' data.
2026-05-11 23:14:06 -05:00
ScreenTinker f88805f36d fix(schedule): add delete button to schedule edit modal so schedules can be removed from the UI (DELETE /api/schedules/:id already existed) 2026-05-11 23:05:03 -05:00
ScreenTinker 0b9aa56e75 Phase 2.2m: schedules.js scoped to workspace_id; schedule.workspace_id inherited from target (device/group); fixes 6 pre-existing cross-tenant leaks (POST content/widget/layout/playlist accepted with no check, PUT verifyOwnership rewrite across all 6 polymorphic targets) 2026-05-11 23:03:54 -05:00
ScreenTinker a77ab365dd fix(dashboard): selection bar surfaces 'pick 1 more' hint when 1 display selected so the disabled Create Video Wall button isn't silently unresponsive 2026-05-11 22:56:15 -05:00
ScreenTinker b6c90d3421 Phase 2.2l: video-walls.js scoped to workspace_id; fixes 4 pre-existing cross-tenant leaks (POST playlist_id, PUT playlist/content/leader_device, PUT /devices, PUT /content); drops dead admin/team_members code paths left over from Phase 2.1 role rename; team_id column noted for future cleanup 2026-05-11 22:56:08 -05:00
ScreenTinker 52e68ac490 fix(playlists): POST /publish returns items with pi.id so post-publish delete works without refresh; future refactor candidate to share SELECT shape with GET /:id 2026-05-11 22:28:59 -05:00
ScreenTinker 833e84578e Phase 2.2k: playlists.js scoped to workspace_id; fixes 3 pre-existing cross-tenant leaks (content add, widget add with NO existing check, device assign); content.js snapshot-scrub bundle; status.js export endpoint deferred to dedicated slice 2026-05-11 22:22:18 -05:00
ScreenTinker 90fe6e0f9a Phase 2.2j: assignments.js scoped to workspace_id via playlist.workspace_id; fixes 3 pre-existing cross-tenant leaks (content add, widget add with NO existing check, copy-to cross-workspace); ensureDevicePlaylist loop-closer; status.js playlist INSERT bundle 2026-05-11 22:12:13 -05:00
ScreenTinker e17538b186 Phase 2.2i: device-groups.js scoped to workspace_id; fixes 3 pre-existing cross-tenant leaks (group device add, bulk content assign, bulk playlist assign); pre-emptive workspace_id stamp on ensureDevicePlaylist helper 2026-05-11 21:58:13 -05:00
ScreenTinker c7f9d014ca Phase 2.2h: layouts.js scoped to workspace_id; templates via is_template path; fixes pre-existing PUT /device/:deviceId cross-tenant layout-assignment leak 2026-05-11 21:45:28 -05:00
ScreenTinker f17d757ba0 Phase 2.2g: reports.js scoped to workspace_id; fixes pre-existing /export and /uptime cross-tenant leaks 2026-05-11 21:36:54 -05:00
ScreenTinker 0d642e4d80 Phase 2.2f: white-label.js scoped to workspace_id; requireWorkspaceAdmin gate; status.js bundle 2026-05-11 21:30:22 -05:00
ScreenTinker 806c931e43 Phase 2.2e: kiosk.js scoped to workspace_id; import kiosk INSERT bundled 2026-05-11 21:20:18 -05:00
ScreenTinker efce13e05d Phase 2.2d: widgets.js scoped to workspace_id; import + widget-reference defense bundled 2026-05-11 21:13:51 -05:00
ScreenTinker a4610e8d0d Phase 2.2c: content_folders gets workspace_id (schema + backfill); folders.js scoped; content.js folder-move strict same-workspace 2026-05-11 21:04:03 -05:00
ScreenTinker a5dbc5d665 Phase 2.2b: content.js + status.js import scoped to workspace_id; uploads stamp workspace_id 2026-05-11 20:50:25 -05:00
ScreenTinker afd2a10df2 Phase 2.2a: devices.js scoped to workspace_id; pair flow stamps workspace_id on claim 2026-05-11 20:33:58 -05:00
ScreenTinker ac3eb74122 i18n: register Italian locale in language registry (followup to PR #2) 2026-05-11 20:05:09 -05:00
ScreenTinker 2954fd1a84 Phase 2.1: tenancy middleware, permission helpers, JWT workspace context, frontend + backend role-rename compat 2026-05-11 20:02:00 -05:00
ScreenTinker d8492f3720 Phase 1: multi-tenancy design doc + migration scripts 2026-05-11 19:37:15 -05:00
screentinker fc84ab8d8b
Merge pull request #2 from albanobattistella/patch-1
Add Italian Translation
2026-05-11 19:24:07 -05:00
albanobattistella 9f1ca2e177
Add Italian Translation 2026-05-09 15:58:48 +02:00
ScreenTinker 45a6800621 fix: log real client IPs through Cloudflare instead of CF edge
Express's req.ip was resolving to a Cloudflare edge address (e.g.
172.70.x.x) for any request fronted by Cloudflare, because trust proxy
was set to '1' — that trusts the immediate hop, which IS Cloudflare.
All activity_log rows from API paths captured the proxy, not the
client. The WebSocket path was unaffected and recorded the real IP.

Two layers of defense:

1. trust proxy now lists Cloudflare's published v4 + v6 ranges plus
   loopback / linklocal / uniquelocal (config/cloudflareIps.js). With
   this list req.ip resolves to the original client when fronted by
   CF, and X-Forwarded-For from any non-trusted source is ignored —
   so the value can't be spoofed.

2. New getClientIp(req) helper in services/activity.js prefers the
   CF-Connecting-IP header but only honors it when the immediate TCP
   peer is itself a trusted address. Same gate as trust proxy, so a
   visitor who hits the origin directly with a forged header is
   logged at their real address.

Routed all five activity-log call sites (auth login success/failure,
admin password reset, generic activityLogger middleware, and the
in-memory rate-limiter key) through the helper.

Logging-only change. No schema changes. Existing rows are not
modified — fix applies to new entries going forward.

Verified locally:
- Bare loopback hit logs 127.0.0.1 (not a proxy address).
- Helper unit cases including an untrusted peer (203.0.113.7) sending
  a forged CF-Connecting-IP correctly fall back to the real peer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:26:37 -05:00
ScreenTinker 2068bc8833 Video walls: free-form canvas editor, leader-driven sync, group dissolve, progress bars
Wall editor: replaces the small grid with a Figma-style pan/zoom canvas. Each
display is a rectangle that can be dragged/resized to match its physical
arrangement; a separate semi-transparent player rect overlays the screens and
defines what content plays where. Drag empty space to pan, wheel to zoom,
"Center" button auto-fits content. Per-rect numeric x/y/w/h panel; arrow keys
nudge by 1px (10px with shift). Negative coordinates supported for screens
offset above/left of the origin. Coords rounded to integers on save.

Wall rendering: each device receives screen_rect + player_rect, maps the
player into its viewport with vw/vh and object-fit:fill so vertical position
of every source pixel is identical across devices that share viewport height.
Leader emits wall:sync at 4Hz with sent_at timestamp; followers apply
latency-adjusted target and use playbackRate ±3% for sub-300ms drift,
hard-seek for >300ms. Followers stay muted; leader unmutes via gesture with
AudioContext priming and pause+play retry to bypass Firefox autoplay.
"Tap to enable audio" overlay as a final fallback.

Reconnect handling: server re-evaluates leader on device:register so the
top-left tile reclaims leadership when it returns. Followers emit
wall:sync-request on entering wall mode (incl. reconnect) so they snap to
position immediately instead of drifting until the next periodic tick.

Group dissolve: removing a device from its last group clears its playlist
to mirror wall-leave semantics. Leaving a group with playlists on remaining
groups inherits the next group's playlist.

Dashboard: walls render as their own card section (hidden the device cards
they contain). Multi-select checkboxes on cards + "Create Video Wall" toolbar
action that creates the wall, removes devices from groups, and opens the
editor. dashboard:wall-changed broadcast triggers live re-render. Per-card
playback progress bar driven by play_start events forwarded from devices.

Security: PUT /walls/:id/devices verifies caller owns each device (or has
team-owner access via the widgets pattern), preventing cross-tenant device
takeover. wall:sync and wall:sync-request validate that the sending device
is a member of the named wall; relay re-stamps device_id with currentDeviceId
so clients can't spoof or shadow-exclude peers.

Schema: video_walls += player_x/y/width/height, playlist_id;
video_wall_devices += canvas_x/y/width/height. All idempotent migrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:11:16 -05:00
ScreenTinker 388e9e6ab8 Admin password reset + widget visibility fix
Password reset for other users:
- New PUT /api/auth/users/:id/password endpoint
- Superadmin can reset any local user; admin can reset role=user
  members of teams they own only (cannot reset other admins or
  superadmins, cannot self-reset — that goes through PUT /me with
  current_password)
- OAuth users are excluded (no password to reset)
- Rate-limited 20 req/min/IP to cap blast radius if an admin session
  is compromised
- Explicit audit log entry "password_reset_for_user / target: <email>"
  on every reset; activity logger's summarizeAction never reads the
  password field, so the password value is not stored anywhere

Frontend: Reset Password button in the Admin user table and Settings
> User Management table. Shown only for local-auth users that aren't
the current user; prompts for an 8+ char password.

Widgets visibility fix:
- routes/widgets.js had `const isAdmin = req.user.role === 'superadmin'`
  which mislabeled superadmin as admin and silently restricted real
  admins (role=admin) to seeing only their own widgets. Now matches
  /auth/users behavior: superadmin sees all, admin sees own + public
  + widgets owned by members of teams they own, user sees own + public.

7 new i18n keys (admin.reset_password, admin.prompt_reset_password,
admin.toast.password_min_8, admin.toast.password_reset, and the
matching settings.user.* / settings.toast.* trio). 1024 keys total,
parity 100% across en/es/fr/de/pt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:45:25 -05:00
ScreenTinker dec56506f9 i18n: add Android localized string resources
Adds values-{es,fr,de,pt,hi}/strings.xml mirroring values/strings.xml.
Two strings: app_name (kept as RemoteDisplay across all locales) and
the accessibility service description (translated).

Hindi is a copy of English by design — same approach as the web's
empty hi.js. Native review can replace the en text in place once
done; Android picks the right file based on device language.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:20:14 -05:00
ScreenTinker aebaacf2c1 i18n batch 7: index.html modal + player overlay
- Add-Display modal in index.html: marked translatable elements with
  data-i18n / data-i18n-placeholder / data-i18n-html attributes
- app.js: translateStaticDom() walks data-i18n* on init and on every
  language-changed event so static HTML stays in sync
- server/player/index.html: standalone player gets its own inline
  PLAYER_I18N table (en/es/fr/de/pt) with a tiny _t() helper. Reads
  rd_lang from localStorage (set by dashboard) so the player picks up
  the same language. Translates info overlay, setup screen, and
  status messages.
- 1018 keys total in dashboard locales, parity 100%.

This completes the wiring; Android resources are next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:19:06 -05:00
ScreenTinker 6d6f901ef4 i18n batch 6: wire teams + activity + help (~62 keys)
- teams.js: list, detail with members + shared devices, invite/role
  controls, all toasts
- activity.js: page chrome, action verb/noun mapping translated through
  t() so the audit log reads naturally in each language
- help.js: page chrome translated; guides and FAQ body content kept
  in English with a comment explaining why (machine-translated docs
  read worse than English source)
- 1008 keys total, parity 100% across en/es/fr/de/pt

All 16 dashboard views now use t(). index.html modal, player overlay,
and Android resources still pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:16:21 -05:00
ScreenTinker 7a17bb5079 i18n batch 5: wire layout-editor + video-wall + billing (~85 keys)
- layout-editor.js: list with templates + custom, zone editor with
  drag/resize and properties panel
- video-wall.js: list with grid preview, editor with grid config,
  bezel inputs, drag-and-drop device placement
- billing.js: current plan card, plans grid with checkout buttons,
  Stripe portal integration
- 943 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:13:38 -05:00
ScreenTinker f4a81d7be2 i18n batch 4: wire schedule + reports + kiosk (~95 keys)
- schedule.js: weekly calendar, add/edit modal with target/recurrence,
  hour labels, day-of-week headers
- reports.js: filters, summary cards, top-content + by-device tables,
  daily/hourly charts
- kiosk.js: list + editor, page settings, style controls, button list
  with action types
- 838 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:09:32 -05:00
ScreenTinker 457a2e4dd4 i18n batch 3b: wire onboarding.js + admin.js (~84 keys)
- Onboarding: 5-step wizard (welcome, get player, pair, upload, done)
  with translated step titles, content, prompts, error messages
- Admin: superadmin user table, plans, system info, role/plan
  selectors, delete confirms
- 750 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:04:23 -05:00
ScreenTinker 04891bccee i18n batch 3a: wire playlists.js (~65 keys)
- List view: tags, item/display pluralization, empty state, load errors
- Detail view: draft banner, inline rename/description, items list
- Drag-reorder + up/down buttons, duration editor
- Add-item modal with content/widgets tabs and search
- 671 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:00:52 -05:00
ScreenTinker 103803fb92 i18n batch 2b: wire designer.js (~80 keys)
- All 12 element types (text, heading, image, video, clock, date,
  weather, ticker, shape, qr, countdown, webpage)
- Background swatches, properties panel, layers list
- Translated prompts for video/weather/RSS/QR/countdown/webpage URLs
- Toasts for publish, export, load, invalid file
- 612 keys total, parity 100% across en/es/fr/de/pt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:57:12 -05:00
ScreenTinker 0743901e48 i18n batch 2a: wire widgets.js (~107 keys)
- All widget types (clock/weather/rss/text/webpage/social/directory-board)
  with localized names + descriptions
- Full Directory Board editor (categories, entries, logo, backgrounds)
- Content picker overlay
- Confirms, toasts, empty states
- 532 keys total, 100% parity across en/es/fr/de/pt

Designer.js follows in batch 2b.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:52:31 -05:00
ScreenTinker eccf4b7af1 i18n batch 1/6: wire device-detail + settings (~242 keys)
- device-detail.js: tabs, draft banner, layout selector, info cards,
  uptime timeline, controls, remote tab, playlist items, copy/assign
  modals, all toasts and confirms
- settings.js: account, change password, license, user management,
  white-label, server info, setup guide, your data export/import,
  language selector, about
- es/fr/de/pt all at 425/425 key parity; hi skeleton untouched
- Native review still recommended before publicizing as fully supported

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:47:17 -05:00
ScreenTinker 8e7a093150 i18n: extract all strings, add 6 language translations, restructure i18n module
Session 1 of 2 of the i18n rollout.

- Split i18n module into per-language files under frontend/js/i18n/ so a
  translator can edit one language without touching the others.
- Add Portuguese (pt) and seed Hindi (hi). Hindi is intentionally a skeleton
  -- 0 keys, full English fallback -- because we have an active Indian user
  and would rather ship "no Hindi" than ship machine-quality Hindi that
  could read as unprofessional or get formality/gender register wrong.
- 183 keys, 100% parity across en/es/fr/de/pt; native review still
  recommended before publicizing as "fully supported".
- Add t(key, vars) variable substitution and tn(keyBase, n, vars) plural
  helper for _one/_other key pairs.
- setLanguage() now triggers a CustomEvent + HashChangeEvent so the
  existing hash router naturally re-renders the current view, plus a
  subscriber pattern for nav labels rendered once outside the router.
- Wire t() into 3 high-traffic views end-to-end: dashboard, login,
  content-library. Sidebar nav labels in app.js update on language change.
- The remaining 16 views still ship with hardcoded English; they will be
  wired in session 2. The t() lookup is robust against unwired views, so
  the dashboard works in 5 languages while clicking into e.g. Schedule
  still shows English. No regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:25:22 -05:00
ScreenTinker a2c8ab4336 Match YouTube oEmbed embed format (revert nocookie, add referrerpolicy) to fix Error 153 2026-04-29 11:32:57 -05:00
ScreenTinker a273e5b2b6 Switch YouTube embed to youtube-nocookie.com to avoid Error 153 from tracking blockers 2026-04-29 11:28:49 -05:00
ScreenTinker 8bfb4584a1 Ignore local video/ directory 2026-04-29 11:26:24 -05:00
ScreenTinker a27738120a Add YouTube video embed to landing page 2026-04-29 11:25:29 -05:00
ScreenTinker 19b62fdc1b Fix landing-page comparison: ScreenTinker 15-device price is \$1,188 not \$989
The Pro plan is \$99/mo flat, so 15 devices for a year = \$1,188. The
landing page's compare table mistakenly showed \$989, which would imply
\$82.42/mo and contradicts every other place the price is quoted (the
comparison pages, the demo video, the pricing cards).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:23:58 -05:00
ScreenTinker 25ab1c485b SEO: add meta tags, sitemap, robots.txt, comparison pages, guides, internal linking
Landing page (frontend/landing.html):
- Title now includes "Self-Hosted" for that keyword
- Description appended "MIT licensed."
- Keywords aligned to spec (digital signage raspberry pi, digital
  signage android tv, video wall software, kiosk software, etc.)
- SoftwareApplication JSON-LD: added applicationSubCategory
  "DigitalSignage", license URL, refreshed description
- Image alt text + og:image:alt + twitter:image:alt now include
  "open-source digital signage"
- New Resources section above the CTA with 6 cards linking to all
  new guides and comparison pages
- Footer rewritten as a 5-column grid (Brand / Guides / Compare /
  Project / Legal) with the new internal links

New SEO pages, all dark-themed, mobile-responsive, ASCII-only:
- frontend/css/seo-page.css (shared nav/footer/article/table styles)
- frontend/compare/yodeck-alternative.html
- frontend/compare/screencloud-alternative.html
- frontend/compare/optisigns-alternative.html
- frontend/guides/raspberry-pi-digital-signage.html
- frontend/guides/digital-signage-android-tv.html
- frontend/guides/self-hosted-digital-signage.html

Each new page has unique title/description/canonical, OG and Twitter
card tags, BreadcrumbList JSON-LD, single h1, proper h2/h3 nesting,
visible breadcrumb, comparison table or step-by-step ordered list,
"Related guides" cross-link block, and a CTA.

Sitemap (frontend/sitemap.xml): added all 6 new URLs with appropriate
priority (0.8 for compare pages, 0.9 for guides). Existing landing
(1.0) and legal pages preserved.

Robots (frontend/robots.txt): allow /compare/ and /guides/, disallow
/player (was previously allowed by mistake).

Server (server/server.js): added explicit GET /sitemap.xml and
GET /robots.txt routes ahead of the static middleware so the
Content-Type is guaranteed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:54:32 -05:00
ScreenTinker b2aa7fab54 Player: keep video playing if unmute is blocked
video.play().catch(() => {}) silently swallowed the rejection from the
browser's autoplay policy, so when a user click triggered the unmute
path the video paused (browser side-effect of unmuting a muted-autoplay
video) and never resumed.

Surface the play() rejection in the log, and fall back to muted playback
if the unmuted play() is blocked. Same for the YT side: explicitly set
volume on unmute. Bumped SW cache to v9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:18:32 -05:00
ScreenTinker a3551a2654 Player: only request fullscreen on real user clicks
The remote-control feature dispatches synthetic click events on the
player when the dashboard forwards touches. The global click handler
called requestFullscreen() on every click, but the browser only honors
that API for trusted user gestures — synthetic events rejected with
"Permissions check failed" / "API can only be initiated by a user
gesture", spamming the console for the duration of any remote session.

Gate the fullscreen request on event.isTrusted. Local user clicks still
trigger fullscreen; remote-control taps no longer try (and fail).
Bumped SW cache to v8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:13:58 -05:00
ScreenTinker 63dcc2b656 Drag-and-drop devices into groups on the dashboard
Device cards are now draggable. Group sections accept drops to add
membership (mirroring the Manage modal — same confirmation if the
device is already in another group). The Ungrouped section also
accepts drops to remove the device from every group it's in.

The existing Manage modal still works for bulk add/remove and for
finding devices not currently visible. Click-to-open on a card still
works; drag is only triggered on actual mouse movement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:54:33 -05:00
ScreenTinker 9b26b4930b Make breadcrumb a drop target for moving content out of folders
Once inside a folder, the only drop targets shown were that folder's
own subfolders — no way to drag a file back up to root or to a parent
without opening the edit modal. Breadcrumb segments now accept content
drops: drop on 'All Content' to move to root, or onto a parent folder
name to move there. The edit modal still works for cross-branch moves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:51:02 -05:00
ScreenTinker 66a137cffe Android: bump to 1.7.8 + fix safeOn return type
Released APK 1.7.8 includes the OOM/crash-loop fix, WebSocket crash
hardening, and the http(s)-only ImageLoader scheme guard. Bumped
versionCode 10 -> 11 and versionName 1.7.7 -> 1.7.8 so existing
1.7.7 installs auto-update on the next UpdateChecker poll.

Also fixed the safeOn extension function: Socket.on() returns Emitter,
not Socket, so the original `return on(...)` failed compile with a
type mismatch. Switched to `on(...); return this` for proper chaining.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:45:18 -05:00
ScreenTinker a4c85eaabc Remove playerContainer position:relative override that nuked YT iframe
createYoutubeEmbed set container.style.position = 'relative' to anchor
the click-to-unmute overlay. That overrode #playerContainer's
position:fixed/inset:0 — the container fell into normal flow with
zero height (the YT iframe inside has no intrinsic size), so the new
absolute-positioned iframe rendered as 100% of 0 = black screen.

The container is already position:fixed, so absolute children anchor
to it correctly without the override. Removed the line. Bumped SW
cache to v7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:36:39 -05:00
ScreenTinker fb0a7f48dd Force YouTube iframe fullscreen with absolute positioning
The previous CSS fix used 100% width/height but YT.Player can bake in
300x150 fallback pixel dimensions if the placeholder isn't laid out at
construction time. Inline pixel dimensions beat percentage CSS at
equal specificity, so the iframe stayed small.

Use absolute positioning with !important to force fullscreen over
whatever YT set inline. Bumped sw cache to v6 to invalidate the
previously-cached player HTML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:34:40 -05:00
ScreenTinker ed46011ae4 Pin YouTube iframe to fill the player container
The .zone iframe sizing rule only applies to multi-zone layouts. In
fullscreen single-zone mode the YT IFrame API replaces our placeholder
div with an iframe directly inside #playerContainer, where no CSS rule
sized it — leaving it at the iframe default size (~300x150) and
producing a tiny square in the corner. Added explicit rules so any
iframe child of #playerContainer fills the viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:59:23 -05:00
ScreenTinker fb58256b1c Fix YouTube autoplay block from stale localStorage gesture flag
userHasInteracted was initialized from localStorage('rd_audio_unlocked')
on every page load. Browser autoplay policy is per-document, so a flag
from a prior session does not actually grant autoplay rights — but the
player code used it to decide whether to start the YouTube embed muted
(autoplay-able) or unmuted (blocked). Result: kiosks with the flag set
loaded a YT embed with mute=0 that the browser refused to start.

- userHasInteracted now always starts as false. The cold-load tap
  overlay flips it to true on real gesture; the 5s auto-dismiss leaves
  it false and playback stays muted (still allowed).
- unlockAudio() now also calls activeYtPlayer.unMute() so the muted
  embed unmutes immediately when the user finally taps the overlay.
- Removed the now-unused localStorage writes of rd_audio_unlocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:56:49 -05:00
ScreenTinker f951d51214 Always show tap overlay on player cold load
Browser autoplay policy is per-document — a previous session's
localStorage flag does not grant the new page autoplay rights. The
'audio previously unlocked, skipping tap overlay' branch was racing
with YouTube's autoplay block, leaving the player stuck on a paused
embed.

Removed the skip-overlay optimization. The existing 5s auto-dismiss
+ muted-connect fallback still handles unattended kiosks, and a real
user only needs to tap once per cold load to get audio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:54:19 -05:00
ScreenTinker 06ba054898 Fix web player TDZ crash on cached-playlist startup
The cached-playlist restore at the top of the script synchronously calls
playCurrentItem -> renderContent -> createYoutubeEmbed, which references
ytGeneration / activeYtPlayer / ytApiReady / ytApiCallbacks. Those were
declared with `let` further down in the script, so the references hit
the temporal dead zone and threw on every cold start with a YouTube
item in the cached playlist:

  Uncaught ReferenceError: can't access lexical declaration
  'ytGeneration' before initialization

Hoisted the four declarations to the top of the script alongside the
other player state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:51:35 -05:00
ScreenTinker f8cc62308f Fix screenshot fallback query and API 404 hang
Two pre-existing bugs surfaced during deploy:

- /api/devices/:id/screenshot fell back to a query referencing
  screenshots.created_at, but the schema column is captured_at. Threw
  SqliteError 500 whenever the in-memory cache was cold (e.g. just
  after a server restart).

- The SPA catch-all at /* served index.html for non-/api paths but did
  nothing for unmatched /api/ paths — the response hung until the
  upstream timeout (524 from Cloudflare at 15s). Now returns 404 JSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:49:10 -05:00
ScreenTinker 8ec33721f7 Security: sanitize notes, add CSP headers, tighten CORS
LOW 1 (notes XSS): device.notes textarea content now goes through
esc(). Notes weren't in the sanitizeBody allow-list at write time, so
HTML in the field would render unescaped on the device-detail page.

LOW 2 (CSP): enabled Helmet contentSecurityPolicy with default-src
'self', script-src 'self', style-src 'self' 'unsafe-inline', plus the
data:/blob:/https: image and media sources the player needs. Strict
script-src blocks <script> injection; script-src-attr 'unsafe-inline'
keeps existing inline onclick handlers working until they can be
refactored to addEventListener (TODO comment in code).

  CSP applies to /app and most other paths. Skipped on the public
  widget and kiosk render endpoints, the landing page, and /player —
  those legitimately need inline scripts/styles. upgrade-insecure-
  requests is explicitly disabled so HTTP-only self-hosted LAN
  deployments aren't broken.

  Refactored two inline onclick handlers in index.html to data-close-
  modal attributes wired by a delegated listener in app.js. Was the
  only blocker for /app under strict script-src.

LOW 3 (CORS): Express CORS now only allows screentinker.com (and
subdomains) + localhost in production. SELF_HOSTED=true bypasses the
allowlist (operator owns their deployment). Development mode stays
open. Same policy applied to the Socket.IO CORS config which was
previously origin: '*'. Native clients (Android, server-to-server,
kiosk iframes) send no Origin and pass through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:31 -05:00
ScreenTinker c105a5941e Security: fix IDORs, XSS, rate limits, SSRF validation
HIGH 1 (teams IDOR): POST/DELETE /api/teams/:id/devices now require the
caller to own the device before assigning or detaching it. Without this
check, any team member could pull any device into their team via UUID
guess and gain remote-control access.

HIGH 2 (schedules IDOR): PUT /api/schedules/:id now re-verifies
ownership of every changed target field — device_id, group_id,
content_id, widget_id, layout_id, playlist_id. Previously only the
schedule owner was checked, letting users fire arbitrary content on
victim devices via update.

HIGH 3 (filename XSS): file.originalname captured by multer bypassed
sanitizeBody. New safeFilename() wraps every INSERT path (multipart
upload, remote URL, YouTube). Frontend sinks now go through esc() in
content-library.js, device-detail.js, video-wall.js. Web player gets
an inline escHtml helper for its info overlay where filenames, device
name, and serverUrl land in innerHTML.

HIGH 4 (kiosk public XSS): config.idleTimeout is now coerced via the
existing safeNumber() helper at both interpolation sites. A crafted
value with a newline can no longer escape the JS line comment to
inject arbitrary code into the public render endpoint.

HIGH 5 (folder DoS): POST /api/folders enforces a per-user cap of 100
folders (429 on overflow). Superadmin exempt.

MED 1 (SSRF): ImageLoader.decodeUrl rejects any URL scheme other than
http(s) so a malicious remote_url can't read local files via file://.
On the server, validateRemoteUrl() is extracted and now also runs on
PUT /api/content/:id remote_url updates — previously the SSRF check
only fired on POST.

MED 2 (fingerprint takeover): the WS device:register fingerprint
reclaim path now rejects takeover while the target device is online or
within 24h of its last heartbeat. A leaked fingerprint can no longer
hijack an active display.

MED 3 (npm audit): bumped uuid 9.x -> 14.0.0 (v3/v5/v6 buffer bounds
CVE; we only use v4 so not exploitable, but clears the audit). path-
to-regexp resolved to 0.1.13 via npm audit fix. 0 vulns remaining.

MED 4 (folder admin consistency): ownedFolder() and the content.js
folder_id move check now both treat only superadmin as privileged,
matching GET /api/folders. Previously a plain "admin" could rename
or delete folders they couldn't see, and could move content into
folders they couldn't list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:18 -05:00
ScreenTinker 76a0076b65 Fix UTF-8 encoding for special characters in filenames
multer/busboy decode multipart filename headers as latin1 by default,
which mangled umlauts and other non-ASCII characters end-to-end
(Größe.jpg arrived as Größe.jpg and was stored that way). Setting
defParamCharset: 'utf8' on the multer options makes the entire
upload pipeline consistent UTF-8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:41 -05:00
ScreenTinker fcecf805ed Add media folder organization to content library
New content_folders table with hierarchical parent_id and per-user
scoping. content.folder_id added (ON DELETE SET NULL so deleting a
folder drops items back to root). New /api/folders route exposes
list/create/rename/move/delete with cycle detection on move.

Content library UI: breadcrumb navigation, subfolder grid, "+ New
Folder" creates inside the current folder, drag-and-drop content
items onto folder cards to move them, and the edit modal has a
folder dropdown showing each folder's full path.

Per-user scoping is enforced server-side: every folder query
filters by user_id, and folder ownership is checked on both folder
mutations and content.folder_id updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:36 -05:00
ScreenTinker 8866e305f0 Fix Android app crash on WebSocket connection loss
Every Socket.IO listener now goes through a safeOn helper that wraps
the body in try/catch(Throwable). Unsafe args[0] as JSONObject and
data.getString() patterns replaced with firstOrNull as? JSONObject
and optString — a malformed payload from the server, or a transient
state error during disconnect, no longer surfaces as an unhandled
exception on the IO thread.

Reconnection now uses explicit exponential backoff with jitter
(1s → 60s, randomizationFactor 0.5) so a fleet doesn't reconnect in
lockstep after a server blip. EVENT_DISCONNECT stops the heartbeat
while disconnected; the player keeps showing cached content. register,
sendHeartbeat, requestPlaylistRefresh, sendScreenshot, sendContentAck,
sendPlaybackState, and disconnect are all wrapped — telemetry / WiFi
service calls can throw on some devices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:26 -05:00
ScreenTinker cd6e39a4a7 Fix Android app OOM crash on 4K images and crash loop recovery
A 4K image assigned to a 1080p display decoded as a ~33 MB ARGB_8888
bitmap and OOM'd. Worse, the cached playlist on disk meant relaunch
hit the same image and crashed again — only a reinstall recovered.

New ImageLoader utility reads bounds via inJustDecodeBounds, computes
inSampleSize against the device screen (or zone size for multi-zone
layouts), and returns null on OOM/Throwable so callers skip the item
instead of crashing. MediaPlayerManager exposes an onImageError
callback wired to playlistController.next() so a bad item advances
the playlist. The cached-playlist restore in onCreate now catches
Throwable (was Exception) and clears the cache on any failure,
breaking the crash loop. android:largeHeap="true" added as belt and
braces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:10 -05:00
ScreenTinker ee6888e737 Fix display duplication on WebSocket reconnect
Server-side: when a device reconnects on a fresh socket while the old
TCP zombie is still around, the old socket's eventual disconnect handler
flipped the device offline and removed the new heartbeat entry. Now we
proactively evict any prior socket on register and ignore disconnects
from sockets that are no longer the registered one for that device_id.

Frontend: dedupe devices by id from the API response and only render
each device in the first group it belongs to (multi-group membership
is still tracked for the Manage modal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:13:00 -05:00
ScreenTinker 05f70b7910 Update ToS: add CSAM policy, fix MIT license conflict, add governing law
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:26:32 -05:00
ScreenTinker c2b1bb20ae Fix stale setup.sh references in Pi installer
Curl-pipe URLs, --help output, clone-and-run path, and the root-check
error message all referenced pi-setup.sh / setup.sh / screentinker/pi,
none of which exist. Point them all at the actual filename and path:
scripts/raspberry-pi-setup.sh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:28:26 -05:00
ScreenTinker 261f74e1e4 Rewrite Pi setup script as all-in-one installer
Turns the Raspberry Pi script from a basic Chromium kiosk launcher
into a full installer with two modes:

- All-in-One: installs Node.js, clones the repo, runs the server
  on port 3001, and launches the kiosk pointing at localhost. One
  Pi does everything.
- Player-Only: connects to an existing server; same kiosk behavior
  as before but with better Chromium flags and crash-flag cleanup.

Other changes:
- Detects Pi OS Lite vs Desktop and adjusts strategy (startx + vt1
  for Lite, plain kiosk launcher for Desktop)
- Auto-login on tty1 for Lite installs
- GPU memory, overscan, console-blanking, and watchdog tweaks
- screentinker-{status,update,logs} management commands
- MOTD with command hints
- Cleans up the legacy remotedisplay.service / kiosk script on
  upgrade so old installs migrate cleanly
- set -euo pipefail, root check, architecture check, tee'd log at
  /var/log/screentinker-setup.log

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:26:49 -05:00
ScreenTinker 846d61a1b0 Add Discord link and refresh feature copy
- README + landing page footer now link to the community Discord
- Landing page feature grid gains Playlists, Directory Board,
  Offline Resilience, and Mobile Dashboard cards; Scheduling and
  Self-Hosted copy updated to mention group-level schedules and
  the DISABLE_REGISTRATION env var
- Structured data featureList expanded to match; Organization
  sameAs now includes Discord
- README feature list clarifies scheduling precedence, mobile
  responsiveness scope, and the auth/IDOR/XSS audit work

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 17:47:00 -05:00
ScreenTinker 2959eaa149 Refresh cached user so admin plan/role changes propagate
The JWT only carries { id, email, role } and the server reads plan_id
fresh from the DB per request, but the frontend cached the user object
in localStorage at login and never refreshed it. After an admin changed
a user's plan, the dashboard kept rendering the old plan until the
user logged out and back in.

Added api.getMe() and a refreshCurrentUser() helper that runs at
startup and on every hashchange. Settings page now fetches the user
fresh via api.getMe() on render, with localStorage as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:38:46 -05:00
ScreenTinker 281a735e84 Fix white-label settings not applying on page load
Root cause: the Settings page loaded /api/white-label into the form
inputs but never applied the saved values (primary_color, bg_color,
brand_name, favicon, custom_css) to the actual document. Nothing in
app.js bootstrap touched branding. So the save hit the DB correctly,
reload kept the DB value correctly, but the page always rendered the
hardcoded defaults from css/variables.css and the static "ScreenTinker"
label in index.html — which looked like the save had reverted.

Fix: new frontend/js/branding.js module that fetches /api/white-label
once at startup (app.js) and applies values to:
  - --accent and --bg-primary CSS vars
  - document.title and the .sidebar-header .logo span text
  - all <link rel="icon">/apple-touch-icon hrefs
  - a <style id="wl-custom-css"> tag for custom_css
  - the theme-color meta tag

Settings save now calls resetBranding() after POST so changes apply
immediately without a reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:36:20 -05:00
ScreenTinker 4392bb460a Add DISABLE_REGISTRATION env var to block public sign-ups
When DISABLE_REGISTRATION=true (or 1), POST /api/auth/register returns
403 with a clear error. OAuth endpoints (/google, /microsoft) also
refuse to auto-create new accounts — existing OAuth users can still
sign in. First-user setup (empty users table) is always allowed so a
fresh install can still be initialized.

GET /api/auth/config now returns registration_enabled so the login
view can hide the "Create Account" button and the trial banner when
registration is off. Absence of the flag is treated as enabled for
back-compat with older servers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:35:32 -05:00
ScreenTinker ea86d70475 README: update feature list to reflect current capabilities
- Playlists: draft/publish workflow with revert
- Device groups: group playlist assignment and group scheduling
- Scheduling: priority-based conflict resolution, group-level schedules,
  device-level overrides
- Widgets: replace "Content designer" line; list all widget types
  including Directory Board
- Offline resilience: Android ContentCache + web player service worker
- Mobile-responsive dashboard
- Account management: password change, profile, email reset
- Security: JWT, rate limiting, ownership checks, XSS/IDOR audits

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:39:07 -05:00
ScreenTinker 6a0e5a28a9 Fix content file access gate for widget references
Extend the public /api/content/:id/file gate to unlock content referenced
by widgets (previously only playlists unlocked it), so device browsers
and kiosk iframes can fetch logos and background images that widgets
embed.

Security: scope the widget lookup to the content owner's widgets only
(w.user_id = content.user_id). Otherwise a user could unlock another
user's content file by creating their own widget whose config references
the victim's content UUID. The pre-existing playlist gate has the same
shape and is left for a separate fix.

Also adds a 30/min rate limit on POST /api/widgets/preview, which
inlines user content as base64 and is memory-intensive.

Perf note: the widgets.config LIKE scan is O(n). Fine at current scale;
revisit with a content_widget_refs join table if the widget table grows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:28:55 -05:00
ScreenTinker 4e4664b603 Add directory board editor UI with content picker, category/entry management
Inline editor with:
- Collapsible categories, reorder up/down, delete
- Entries with identifier, name, subtitle, available toggle
- Add/remove with auto-focus on new row
- Empty state prompts first category
- Theme, scroll speed, column count selectors
- Reusable content picker (single/multi-select) against user's image library
- Logo picker + background image picker (multi) via that picker
- Preview button posts unsaved config to /widgets/preview and shows the
  returned HTML in a modal iframe (srcdoc + injected <base> so relative
  content URLs resolve against our origin)
- Delete confirms with widget name

Also escapes w.name / typeMeta.name / w.id in the widget grid to prevent
stored XSS against admins viewing other users' widgets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:28:47 -05:00
ScreenTinker 08a83c9ba9 Add directory board widget renderer with scrolling, anti-burn-in, dark/light themes
Lobby-style tenant/room directory with vertical marquee, seamless loop via
content cloning, pixel shift + bg pulse for anti-burn-in, rotating background
images with crossfade. Supports logo, title, footer, subtitles per entry,
and Available (green) state. All user strings rendered via textContent in
browser — no server-side HTML escaping of entries needed.

Also refactors render dispatch into renderWidgetHtml() and adds a POST
/preview endpoint that inlines user-owned image content as base64 data
URIs so the editor can preview unsaved widgets. Preview is gated by:
- image/* MIME only
- 10 MB size cap
- user_id ownership check
- path traversal guard via basename + resolve

Unknown widget_type on /preview returns 400.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:28:37 -05:00
ScreenTinker a981171c94 SEO: open-source positioning, GitHub links, OG image, semantic <main>
- Retarget primary keywords ("open-source", "CMS") in title, description,
  OG/Twitter tags and hero h1
- Swap OG/Twitter image from icon-512 to dashboard-preview.png with
  width/height/alt metadata
- Add GitHub link in nav (icon), hero (secondary btn), footer, and a
  new Open Source callout section
- Wrap content in <main> landmark; add width/height on screenshot for
  CLS; add third-party license page to sitemap; Organization schema
  sameAs now points to the GitHub repo

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:56:22 -05:00
ScreenTinker ea80d3aca5 Landing: replace iframe mock with dashboard screenshot
Swaps the live-app iframe for a static PNG of the Displays view.
Faster load, no auth flash, looks sharp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:47:13 -05:00
ScreenTinker 3476f2b7e7 Landing: group Sign In next to Start Free Trial on the right
Removes the far-right floating position; Sign In sits in the nav
cluster alongside the CTA instead of pinned to the viewport edge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:37:41 -05:00
ScreenTinker e0bfa76545 Landing: float Sign In to far top-right, separate from Start Free Trial
Sign In now lives outside the nav-links cluster with margin-left:auto,
pinning it to the top-right corner with visible separation from the
primary CTA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:30:33 -05:00
ScreenTinker 87a935cb74 Landing: fix mobile nav overflow so Sign In stays visible
Pixel 8 Pro portrait (~412px) was clipping Sign In because logo + both
buttons overflowed. Hide logo text below 420px, shorten 'Start Free Trial'
to 'Try Free' on mobile, nowrap nav-links with tighter padding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:27:19 -05:00
ScreenTinker 25f3870472 Landing: keep Sign In button visible on mobile nav
Previously hidden behind the primary CTA; now shows alongside it with
tighter padding on small screens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:24:57 -05:00
ScreenTinker 52297ec618 Settings: add account profile + password change UI
Adds a per-user Account section in Settings with name edit and password
change. Password change requires current password; local auth only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:13:20 -05:00
ScreenTinker 772ead28a2 Fix reset-admin.js: honor recovery token in requireAuth
scripts/reset-admin.js signed a JWT with a synthetic id ("recovery-XXX")
and instructed the operator to paste it into localStorage. But the
requireAuth middleware always SELECTs the user row by id, so every
authed API call under the recovery token returned 401 "User not found"
and the recovery flow was effectively dead.

Fix:
- reset-admin.js now sets a `recovery: true` claim on the JWT.
- requireAuth / optionalAuth short-circuit the DB lookup when
  decoded.recovery === true and synthesize a req.user record in
  memory (role: admin, plan_id: enterprise). The synthetic user is
  never persisted, so FK-constrained writes that expect a real
  user (creating devices, etc.) will still fail — which is fine,
  recovery is only meant to let the operator reset a password or
  create a fresh admin via the Settings UI.

Security: a recovery token still requires the jwtSecret to sign,
so only someone with filesystem access to the server can mint one.
Token TTL remains 1h.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 19:08:49 -05:00
ScreenTinker 8da0e60c20 Mobile: public-facing pages (landing + login)
Login view:
- Remove `margin-left: calc(-1 * var(--sidebar-width))` from the
  centering wrapper. It was a hack to compensate for the sidebar
  offset, but app.js already zeros the app margin on the login
  route. On mobile this was pushing the login card ~240px off
  the left edge of the viewport.
- Use min-height + padding so the card breathes on short screens.
- Drop inline font-size:11px on the support-token input so the
  global .input 16px mobile rule applies (iOS focus-zoom prevention).

app.js:
- Hide the mobile hamburger button on the login route; it has no
  function there since the sidebar is already hidden.

Landing page:
- Scope the old blanket `.nav-links { display: none }` to hide only
  the section anchors + secondary Sign In button, so the primary
  "Start Free Trial" CTA stays visible on mobile.
- Wrap the 5-column Compare table in a horizontal-scroll container
  and set min-width:560px so it scrolls instead of overflowing
  the page.
- Add min-height:44px to .btn on mobile, tighten section padding
  to 16px (from 24px) so content doesn't feel cramped against
  the viewport edge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 18:52:53 -05:00
ScreenTinker 481ae0209a Mobile: fix modal and form control overflow
Inline width:NNNpx beats the .modal { width: 95vw } mobile rule due to
specificity. Convert to max-width:NNNpx;width:95vw on the three affected
modals so they cap at their desktop size but still shrink on mobile:
  - playlists.js add-item modal (560px)
  - device-detail.js assign-playlist modal (650px)
  - content-library.js edit-content modal (500px)

Same fix pattern for fixed-width form controls flagged in QA — selects
and inputs change to max-width:NNNpx;width:100% so they keep their
desktop size but shrink to container on mobile:
  - admin.js role/plan selects (120/130px)
  - teams.js member role + add-device selects (100/200px)
  - content-library.js search input + folder filter (250/180px)
  - onboarding.js pairing code + display name inputs (240px)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 18:48:51 -05:00
ScreenTinker 0bd34544e5 QA fixes: toast aria-live + scope playlist flex-wrap to mobile
- Toast now announces via role="status"/aria-live="polite" by default,
  and role="alert"/aria-live="assertive" for errors. Screen readers
  previously got nothing when notifications appeared.
- Move playlist-item flex-wrap:wrap from inline style into the
  @media (max-width: 768px) block so desktop rows don't wrap controls
  when the viewport is intermediate-narrow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 16:00:41 -05:00
ScreenTinker 8cd5dd518a Playlist: add up/down reorder buttons
Adds accessible up/down arrow buttons alongside the existing drag-to-
reorder handle on each playlist item. Touch users (and keyboard users)
now have a reliable way to re-order without relying on HTML5 drag-drop,
which is effectively unusable on mobile. First/last items have the
respective arrow disabled.

Uses the same /reorder API the drag handler uses, so behavior stays
consistent. flex-wrap on the item container prevents control overflow
on narrow screens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:57:40 -05:00
ScreenTinker 06d3e93e21 Mobile: horizontal-scroll tables + tab fade (Commit 4/4)
- Wrap wide tables (admin, settings, reports) in .table-wrap with
  min-width on the table so they scroll horizontally on narrow screens
  instead of collapsing rows.
- Add global .table-wrap { overflow-x: auto } utility.
- Mobile: add mask-image fade on .tabs right edge to hint scrollability
  when tabs overflow; flex-shrink:0 on .tab keeps labels intact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:56:01 -05:00
ScreenTinker b45d81cfaa Mobile: modals, forms, tap targets, toast (Commit 3/4)
- Buttons: min-height 44px (36px for .btn-sm, 40px for .btn-icon) on mobile
- Inputs/selects/textarea: font-size 16px (prevents iOS focus zoom), min-height 44px
- Pairing input: scaled letter-spacing down so 6 digits fit at 375px width
- Modals at 95vw: tighter header/body/footer padding so content breathes
- Toast container: bar-style full-width (left/right:12px) instead of
  fixed-right 280px that clipped below 400px viewports

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:51:12 -05:00
ScreenTinker 7c8504d593 Mobile: grid + layout reflow (Commit 2/4)
- Dashboard stats row (.dash-stats-row): flex column on mobile
- Content-library toolbar: stack upload area + remote URL + YouTube boxes vertically
- Info grid: 1 col on mobile (was 2 col); device detail metadata reads cleaner
- Content grid: drop to 1 col below 480px (iPhone SE)
- Schedule controls: wrap, device select fills row
- Schedule calendar: already wrapped in overflow-x:auto, kept horizontal-scroll
  approach (future: dedicated mobile day-view)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:50:56 -05:00
ScreenTinker 09dbb4b199 Mobile: sidebar polish (Commit 1/4)
- Move hamburger click + backdrop click out of inline onclick into app.js
- Add aria-label/aria-expanded/aria-controls to hamburger button
- Close drawer on Escape keypress
- Bump hamburger button to 44px, nav-link min-height to 44px (tap targets)
- Bump .content top padding to 68px on mobile to match 44px hamburger

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 15:49:49 -05:00
ScreenTinker 2d3bb55db4 Fix startup crash on existing DB: defer group_id index to migration
The CREATE INDEX on schedules(group_id) in schema.sql ran before the
phase4 migration added the group_id column, crashing on existing databases.
Move the index creation to the migration which already handles it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 07:59:49 -05:00
ScreenTinker 52dd44a3e8 Add group-level scheduling, group playlist assignment, and persist audio unlock
Phase 4 group scheduling: schema migration adds group_id to schedules with
CHECK constraint, scheduler evaluates group+device schedules with priority,
group deletion converts schedules to per-device copies. Dashboard gets
playlist assignment dropdown and current playlist label on group headers.
Player persists audio unlock state in localStorage so version reloads
don't lose audio on unattended displays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:22:42 -05:00
ScreenTinker 2104c9cc9f Auto-reload web player when server code changes
Player polls /api/version every 30s and reloads if the hash changes.
Server hash now includes player/index.html and sw.js so player code
updates are detected without requiring a hard refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:44:47 -05:00
ScreenTinker ad3095cdf5 Fix player video cycling bug and connecting overlay during cached playback
Clear pending advance timers when switching content items to prevent stale
image/widget duration timers from interrupting video playback. Also skip
showing "Connecting..." overlay when cached playlist is already playing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:34:48 -05:00
ScreenTinker d73abc809d Simplify service worker: stop intercepting content requests
The SW was causing "unexpected error" on video/image fetches due to
range request handling, opaque response caching, and stale SW races.

Fix: SW now ONLY caches player page + socket.io JS for offline boot.
Content files are left to browser native HTTP cache (server already
sets Cache-Control: public, max-age=2592000, immutable).

Also: auto-reload player when new SW activates so deploys take effect
immediately without manual hard refresh.

Bumped cache to v5 — activate purges all old caches (including the
broken rd-content-v1 content cache).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:24:15 -05:00
ScreenTinker b4ac2fb821 Fix broken service worker + device auth rejection on playlist refresh
Bug 1 (SW): Rewrote service worker fetch handler:
- Skip range requests (video seeking) to avoid caching partial responses
- Skip non-GET requests entirely
- Use ignoreSearch on cache match to avoid query-param misses
- Don't cache opaque cross-origin responses
- Outer catch on Cache API failures
- Don't intercept catch-all requests (let browser handle natively)
- Bump cache version to v4 to purge broken cached responses

Bug 2 (auth): Playlist refresh register was missing device_token,
causing auth rejection every 5 minutes. Fixed by including token
in the refresh-register emit. Added diagnostic logging on both
client and server for token validation failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 22:18:08 -05:00
ScreenTinker dc7450b6a7 Offline resilience: persist playlist cache for cold-start recovery
Web player:
- Cache playlist JSON to localStorage on every update
- Restore and start playing immediately on boot before connecting
- Clear cache on unpair/reset

Android app:
- Cache playlist JSON to EncryptedSharedPreferences on every update
- Restore cached playlist on cold-start, play from disk-cached content
- Update cache on content deletion, clear on unpair

Server (device socket):
- Fingerprint reconnect: issue fresh token instead of rejecting
- Send device:paired on fingerprint recovery for claimed devices
- Add status logging and dashboard notification on fingerprint reconnect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:49:45 -05:00
ScreenTinker 470197d203 Fix 8 security findings from Phase 3 audit + device-detail banner refresh
Security fixes:
- Critical: Add ownership checks to assignments PUT/:id and DELETE/:id (IDOR)
- Critical: Add ownership checks to assignments copy-to endpoint for both devices
- High: Validate device ownership when adding to device groups
- High: UUID-validate content ID before LIKE query + scope to owner's playlists
- Low: Handle FK violations gracefully in playlist discard (deleted content/widgets)
- Low: Escape mime_type with esc() in playlist item display (XSS)

Bug fix:
- Device-detail mutation handlers now reload full page to show draft banner

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:36:16 -05:00
ScreenTinker f30d8b82cd Unify publish behavior: all edits go to draft, require explicit publish
Remove autoPublish from assignments.js and device-groups.js. All item
mutations (add, update, delete, reorder, copy) now call markDraft
regardless of which UI the edit comes from. Users must explicitly
click Publish to push changes to devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:22:44 -05:00
ScreenTinker 436a3be7f6 Phase 3: playlist publish/draft state with auto-publish from device detail
Schema: add status and published_snapshot columns to playlists table.
Migration snapshots all existing playlists as published (idempotent via schema_migrations).

Devices always receive the published_snapshot, not live playlist_items.
Edits from device-detail/groups auto-publish immediately (display updates instantly).
Edits from playlist detail page go to draft (requires explicit publish).
POST /playlists/:id/publish snapshots and pushes to all devices.
POST /playlists/:id/discard reverts playlist_items from published snapshot.
Content deletion scrubs references from all published snapshots.

Frontend: draft badge in playlist list, prominent yellow banner with publish/discard
buttons on playlist detail and device detail pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 20:52:29 -05:00
214 changed files with 36596 additions and 3396 deletions

38
.dockerignore Normal file
View file

@ -0,0 +1,38 @@
# git + CI
.git
.github
.gitignore
# deps - reinstalled fresh in the build
node_modules
**/node_modules
# mutable state (lives on the /data volume, never baked into the image).
# Note: the *.db* patterns drop the database FILES; we must NOT exclude the
# server/db/ directory itself - it holds database.js + schema.sql.
*.db
*.db-wal
*.db-shm
*.db.*
server/uploads
server/certs
# secrets
.env
**/.env
# not needed at runtime (NOTE: scripts/ IS needed - database.js requires
# scripts/migrate-multitenancy at boot - so it must stay in the image)
server/test
android
tizen
video
*.apk
*.wgt
*.tar.gz
# docs / editor cruft
*.md
.vscode
.idea
.DS_Store

47
.env.example Normal file
View file

@ -0,0 +1,47 @@
# ScreenTinker server configuration — process environment variables.
#
# NOTE: the app reads these from the *process environment* (your systemd unit's
# Environment=/EnvironmentFile=, your container runtime, or your shell). It does
# NOT auto-load this file. Copy the values you need into your process manager.
# This file exists only to document the available options.
# --- Self-hosting ---
# Set to "true" on your own instance. When true, the hosted signup emails
# (welcome to the user + admin notification) are disabled, so a self-hosted
# instance never emits mail from a domain that isn't yours.
SELF_HOSTED=true
# Close public self-service registration — for instances where all accounts are
# provisioned by your team (admin "Add user" / invites). When true, the public
# signup route is blocked (OAuth auto-signup with it) AND the login page hides
# its "Create account" button so the UI matches the backend. First-user setup on
# an empty DB is still allowed so a fresh install can be initialized.
# DISABLE_REGISTRATION=true
# Redirect "/" to the app (/app) instead of serving the marketing landing page.
# For internal-only deployments that don't want the public homepage shown.
# DISABLE_HOMEPAGE=true
# Where new-signup admin notifications are sent. Leave UNSET to disable admin
# notifications entirely — the user's welcome email is unaffected. Self-hosters
# who want to be notified of signups set this to their own address.
# ADMIN_NOTIFY_EMAIL=you@example.com
# Marks THIS deployment as the hosted (screentinker.com) instance. Gates the
# daily activation-nudge sweep (the T+3 "haven't paired a screen yet?" email).
# Leave UNSET on self-hosted instances so a daily bulk sweep never emails your
# user base with our onboarding mail. Only the hosted instance sets this true.
# HOSTED_INSTANCE=true
# --- Outbound email (Microsoft Graph, client-credentials flow) ---
# Required for ANY email (welcome, offline alerts, admin notify) to actually
# send. Leave blank and the app logs "[EMAIL] not configured" instead of sending.
# GRAPH_TENANT_ID=
# GRAPH_CLIENT_ID=
# GRAPH_CLIENT_SECRET=
# GRAPH_SENDER_EMAIL=signage@example.com
# GRAPH_SENDER_NAME=ScreenTinker
# Dev safety net: comma-separated allow-list of recipients. When set, mail to
# any address NOT in the list is suppressed (logged, not sent). Leave UNSET in
# production. Useful locally so test signups can't email real users.
# GRAPH_DEV_RESTRICT_TO=me@example.com

126
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,126 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
# main gets frequent pushes - cancel an in-flight run when a newer commit
# (or rerun) supersedes it, per ref.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Unit tests (node --test)
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm
cache-dependency-path: server/package-lock.json
- run: npm ci
- run: npm test
openapi:
name: OpenAPI spec lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
- name: Lint the public API spec
run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
# Contract integrity: the spec documents ONLY the token-reachable public surface.
# A JWT-only router (admin/auth/provision/...) appearing here is a security flag,
# not a convenience - fail loudly. (The runtime partition test is a separate suite
# that will cross-check the spec against the live mount list.)
- name: Assert spec is public-only
run: |
BAD=$(grep -oE '^ /(admin|auth|workspaces|ai|provision|white-label|status|subscription|stripe|teams|player-debug|contact|tokens)\b' docs/openapi.yaml || true)
if [ -n "$BAD" ]; then echo "::error::JWT-only path(s) leaked into the public spec:"; echo "$BAD"; exit 1; fi
if grep -qE 'unassigned|/prune' docs/openapi.yaml; then echo "::error::token-denied endpoint present in public spec"; exit 1; fi
echo "OK: spec is public-only"
android-test:
name: Android unit tests (Kotlin schedule evaluator vectors)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v3
# ScheduleEvalTest reads the SHARED shared/schedule-vectors.json (wired via
# the test task in app/build.gradle.kts), so a ScheduleEval.kt change that
# breaks the contract fails here.
- name: Kotlin evaluator vector conformance
working-directory: android
run: ./gradlew :app:testDebugUnitTest --no-daemon
smoke:
name: Boot smoke + version check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm
cache-dependency-path: server/package-lock.json
- name: Install deps
working-directory: server
run: npm ci
# Boot against a fresh SQLite db (clean checkout = no db yet). SELF_HOSTED
# makes the first user an admin with no billing. No certs present, so the
# server listens on plain HTTP at :3001. Background it and wait until it
# answers.
- name: Boot server
working-directory: server
env:
SELF_HOSTED: 'true'
run: |
node server.js > "$RUNNER_TEMP/server.log" 2>&1 &
echo $! > "$RUNNER_TEMP/server.pid"
for i in $(seq 1 30); do
curl -sf http://localhost:3001/api/status >/dev/null && exit 0
sleep 1
done
echo "server did not come up within 30s:"; cat "$RUNNER_TEMP/server.log"; exit 1
# Assert the public status endpoint is healthy and reports exactly the
# VERSION file - this is what proves the single-source-of-truth wiring.
- name: Assert /api/status ok and version matches VERSION
run: |
STATUS="$(curl -sf http://localhost:3001/api/status)"
echo "status: $STATUS"
EXPECTED="$(cat VERSION)"
REPORTED="$(echo "$STATUS" | jq -r .version)"
echo "VERSION file: $EXPECTED reported: $REPORTED"
test "$(echo "$STATUS" | jq -r .status)" = "ok"
test "$REPORTED" = "$EXPECTED"
echo "OK: status ok, version $REPORTED matches VERSION"
- name: Stop server
if: always()
run: kill "$(cat "$RUNNER_TEMP/server.pid")" 2>/dev/null || true
# TODO (deferred - needs a tag earlier than HEAD, so meaningful from v1.8.0 on):
# upgrade-path job. Restore a db created by the previous tagged release, boot
# the current code against it, and assert migrations complete and /api/status
# is healthy. Add once a prior release tag exists.

173
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,173 @@
name: Release
# Fires when a version tag is pushed (e.g. v1.8.0). Builds + publishes artifacts
# only - nothing here deploys to production.
on:
push:
tags: ['v*']
permissions:
contents: write # create the GitHub Release
packages: write # push the image to ghcr.io
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false # never cancel a release mid-publish
jobs:
# Fail-fast: a hand-pushed tag that disagrees with VERSION must not publish
# anything (the artifacts would report the wrong version). Gates everything.
verify:
name: Verify tag matches VERSION
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Assert pushed tag equals VERSION
run: |
TAG="${GITHUB_REF_NAME#v}"
FILE="$(cat VERSION)"
echo "pushed tag: ${GITHUB_REF_NAME} (stripped: $TAG) VERSION file: $FILE"
if [ "$TAG" != "$FILE" ]; then
echo "::error::Tag ${GITHUB_REF_NAME} does not match VERSION ($FILE) - refusing to publish."
exit 1
fi
echo "OK: tag matches VERSION ($FILE)"
test:
name: Tests
needs: verify
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm
cache-dependency-path: server/package-lock.json
- run: npm ci
- run: npm test
artifacts:
name: Tarball + Tizen .wgt + GitHub Release
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history, for release notes
- name: Resolve version + previous tag
id: ver
run: |
VERSION="$(cat VERSION)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
PREV="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
# #80: a version carrying a -suffix (e.g. 1.9.0-rc1) is a pre-release.
case "$VERSION" in *-*) PRE=true ;; *) PRE=false ;; esac
echo "prerelease=$PRE" >> "$GITHUB_OUTPUT"
echo "Releasing ${GITHUB_REF_NAME} (version $VERSION, prerelease=$PRE); previous tag: ${PREV:-<none>}"
- name: Build Tizen .wgt (unsigned in CI)
run: |
chmod +x tizen/build-wgt.sh
( cd tizen && ./build-wgt.sh ) # no Tizen CLI on the runner => unsigned zip
cp tizen/ScreenTinker.wgt ScreenTinker.wgt
ls -la ScreenTinker.wgt
- name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh)
run: |
OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz"
tar czf "$OUT" \
--exclude='node_modules' --exclude='.git' --exclude='.github' \
--exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' --exclude='*.db.*' \
--exclude='server/uploads' --exclude='server/certs' --exclude='server/test' \
--exclude='*.apk' \
server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt
echo "TARBALL=$OUT" >> "$GITHUB_ENV"
ls -la "$OUT"
- name: Generate release notes
run: |
PREV="${{ steps.ver.outputs.prev }}"
{
echo "## ScreenTinker ${{ steps.ver.outputs.tag }}"
echo
echo "### Changes"
if [ -n "$PREV" ]; then
git log --no-merges --pretty='- %s' "${PREV}..${{ steps.ver.outputs.tag }}"
else
echo "_First tagged release. Most recent changes:_"
git log --no-merges --pretty='- %s' -n 30 "${{ steps.ver.outputs.tag }}"
fi
echo
echo "### Artifacts"
echo "- \`${TARBALL}\` - bundle: server + frontend source + the Tizen .wgt (the signed Android APK is added at the root during release finalization)."
echo "- \`ScreenTinker.wgt\` - Tizen TV web app, **unsigned - for inspection only**."
echo " Sign it with your own Samsung certificate (Tizen Studio + a profile that includes"
echo " your TV's DUID) to install, or - easiest - point a Tizen TV browser / URL Launcher"
echo " at \`https://<your-instance>/player\` (no signing needed)."
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (pre-release - \`:latest\` is NOT moved)."
else
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
fi
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
} > RELEASE_NOTES.md
cat RELEASE_NOTES.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# #80: pre-release tags publish as a GitHub *pre-release* (not "Latest"),
# which also keeps the /releases/latest API pointing at the last stable.
PRERELEASE_FLAG=""
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && PRERELEASE_FLAG="--prerelease"
gh release create "${{ steps.ver.outputs.tag }}" \
$PRERELEASE_FLAG \
--title "ScreenTinker ${{ steps.ver.outputs.tag }}" \
--notes-file RELEASE_NOTES.md \
"${TARBALL}" \
tizen/ScreenTinker.wgt
docker:
name: Docker image (amd64 + arm64) -> ghcr
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- id: ver
run: |
VERSION="$(cat VERSION)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# #80: move :latest only for final releases - a pre-release (1.9.0-rc1) must
# not repoint :latest onto untested code (anyone on :latest pulls it on restart).
TAGS="ghcr.io/screentinker/screentinker:$VERSION"
case "$VERSION" in
*-*) echo "Pre-release $VERSION: :latest will NOT be moved" ;;
*) TAGS="${TAGS}"$'\n'"ghcr.io/screentinker/screentinker:latest" ;;
esac
{ echo "tags<<__EOF__"; printf '%s\n' "$TAGS"; echo "__EOF__"; } >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.ver.outputs.tags }}
# TODO (deferred): build + sign the Android APK in CI. Requires the release
# keystore + passwords as encrypted Actions secrets. For now the maintainer
# attaches a signed APK out-of-band (and self-hosters mount one at
# /data/ScreenTinker.apk).

17
.gitignore vendored
View file

@ -1,10 +1,12 @@
# Dependencies # Dependencies
node_modules/ node_modules/
# Database # Databases: SQLite files, WAL/SHM sidecars, and any .db.<suffix> backups
server/db/*.db # (e.g. .db.devbak), anywhere in the tree - never commit a database.
server/db/*.db-wal *.db
server/db/*.db-shm *.db-wal
*.db-shm
*.db.*
# Uploads (user content) # Uploads (user content)
server/uploads/ server/uploads/
@ -24,6 +26,8 @@ android/local.properties
android/release-key.jks android/release-key.jks
*.apk *.apk
*.aab *.aab
*.wgt
*.tar.gz
# IDE / Editor # IDE / Editor
.claude/ .claude/
@ -40,3 +44,8 @@ Thumbs.db
# Environment # Environment
.env .env
.env.* .env.*
# ...but DO track the documented template (placeholders only, no secrets)
!.env.example
# Local-only marketing assets
video/

59
CHANGELOG.md Normal file
View file

@ -0,0 +1,59 @@
# Changelog
## 1.9.0 — 2026-06-11
### Added
- **Per-playlist-item schedules.** Each playlist item can carry one or more schedule
blocks — active days, a start/end time-of-day, and optional start/end dates. An item
plays when the screen's local "now" matches at least one block; an item with no
blocks always plays. Edit per item via the clock icon in the playlist editor (a badge
summarises the schedule on each row).
- **#74 dayparting:** time-of-day + day-of-week windows, including overnight windows
that cross midnight (a Fri 22:0002:00 block is active Sat 01:00).
- **#75 auto-expire:** inclusive start/end dates; an item past its end date stops
showing automatically — even on offline screens, because evaluation is on-device.
- All three players (web, Android, Tizen) evaluate schedules client-side against their
own clock, so dayparting and expiry work offline. They share one evaluator contract,
`shared/schedule-vectors.json` — 39 conformance vectors covering DST (US + AU),
overnight-wrap day anchoring, timezone correctness, and date boundaries. CI runs the
vectors against the JS evaluator (node) and the Kotlin port (Gradle/JUnit); the Tizen
copy is byte-identical to the JS source and checked under node.
- Device detail now shows the screen's reported timezone and clock, with a **clock-skew
warning** when the device clock differs from the server by more than 2 minutes (a bad
device clock makes schedules fire at the wrong local time).
### Changed — device-level schedule timezone (behaviour change)
- Device/group **schedule overrides** (the existing calendar feature) are now evaluated
in each device's effective timezone instead of the server's local time. Previously the
`schedules.timezone` field was never applied and "07:00" meant the *server's* 07:00.
Now "07:00" means the *screen's* 07:00 — which is what was intended.
- **Who is affected:** self-hosters whose server timezone differs from their screens'
timezone — their existing device schedules will shift to fire at the screens' local
time. Single-timezone deployments (server and screens in the same zone) are
unaffected. A device with no timezone set and not reporting one falls back to the
server clock (unchanged from before).
### Fixed
- **#81 — release APK is now v1 + v2 + v3 signed.** With `minSdk 26`, the Android Gradle
Plugin defaulted the v1 (JAR) signature *off*, producing a v2-only APK that some
MDM-managed commercial signage (e.g. MAXHUB via the Pivot MDM) silently removes on the
next reboot — so screens that power-cycle nightly lost the app and fell back to the
setup screen. Setting `enableV1Signing = true` had no effect at minSdk ≥ 24; the release
build now re-signs with `apksigner` and a low `--min-sdk-version` to emit the JAR
signature alongside v2/v3. Verified to install and run on Android 14+/API 36 as well.
### Notes
- **Scheduling fails open.** If the on-device evaluator ever errors (bad timezone id,
malformed block), the item **plays** rather than being hidden. A blank screen is worse
than an over-running promo — this is a guarantee, enforced in all three players.
- Windows are enforced at **item boundaries**: a long item finishes before the schedule
is re-checked, so it can overshoot its window by up to its own duration.
- **A single video *with a schedule* now re-renders at each loop boundary** so its window
can be re-evaluated; seamless native looping still applies to unscheduled single videos.
Deliberate tradeoff — a brief seam each loop for a scheduled lone video, in exchange for
its daypart/expiry actually being honoured.
- **Re-publish required:** editing a schedule puts the playlist into draft; publish to
push schedules to devices. Existing published playlists keep playing unchanged until
re-published.
- Players that predate this release ignore the new fields and keep playing everything
(graceful degradation) — update players to honour schedules.

37
Dockerfile Normal file
View file

@ -0,0 +1,37 @@
# ScreenTinker server image: serves the dashboard, the web player, and the
# device API. All mutable state (db, uploads, jwt secret) lives under /data so it
# survives container restarts - mount a volume there. A built ScreenTinker.apk
# can be mounted at /data/ScreenTinker.apk to enable OTA APK downloads.
#
# No TLS in the image: it listens on plain HTTP :3001. Front it with a
# TLS-terminating reverse proxy / Cloudflare in production.
# --- builder: install production deps (native: better-sqlite3, sharp) ---
FROM node:20-slim AS builder
WORKDIR /app/server
# build toolchain in case a native prebuild is missing for the target arch
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev
# --- runtime ---
FROM node:20-slim
ENV NODE_ENV=production
# Relocate all state onto the volume (config.js reads DATA_DIR; unset would use
# the in-repo paths, which we do not want in a container).
ENV DATA_DIR=/data
WORKDIR /app/server
# App source (node_modules/test/db/uploads/certs are excluded via .dockerignore),
# then the built deps, the frontend the server serves, and the VERSION file it
# reads as ../VERSION.
COPY server/ /app/server/
COPY --from=builder /app/server/node_modules /app/server/node_modules
COPY frontend/ /app/frontend/
COPY VERSION /app/VERSION
# database.js requires scripts/migrate-multitenancy at boot
COPY scripts/ /app/scripts/
VOLUME ["/data"]
EXPOSE 3001
CMD ["node", "server.js"]

257
README.md
View file

@ -1,31 +1,79 @@
# ScreenTinker # ScreenTinker
Open-source digital signage management software. Control content on TVs, displays, and kiosks from anywhere. ScreenTinker is self-hosted digital signage software. Manage screens across multiple locations from one dashboard — built for retail, offices, lobbies, and any environment where you need centralized control over what's displayed on remote screens. Open source, multi-tenant, single-developer maintained with direct contact access.
**Hosted version:** [screentinker.com](https://screentinker.com) — free tier available, no credit card required. **Hosted version:** [screentinker.com](https://screentinker.com) — free tier available, no credit card required.
**Community:** [Discord](https://discord.gg/utTdsrqq4Z)
## Features ## Features
- **Playlists** — first-class playlist objects: create, reorder, set per-item duration, share one playlist across multiple displays - **Playlists** — first-class playlist objects: create, reorder, set per-item duration, share one playlist across multiple displays; draft/publish workflow with revert-to-published
- **Device groups** — organize displays into groups, bulk-assign content or playlists, send bulk commands (reboot, screen on/off, launch, update, shutdown) - **Device groups** — organize displays into groups, assign a playlist to an entire group, send bulk commands (reboot, screen on/off, launch, update, shutdown), schedule content group-wide
- **Multi-zone layouts** — split screens into zones with drag-and-drop editor; 7 built-in templates (fullscreen, split, L-bar, PiP, grid) - **Multi-zone layouts** — split screens into zones with drag-and-drop editor; 7 built-in templates (fullscreen, split, L-bar, PiP, grid)
- **Video walls** — combine multiple displays into one screen with bezel compensation, device rotation, and leader-based sync - **Video walls** — combine multiple displays into one screen with bezel compensation, device rotation, and leader-based sync
- **Remote control** — live view, touch injection, key input, power on/off - **Remote control** — live view, touch injection, key input, power on/off
- **Scheduling** — visual weekly calendar with recurrence rules (daily/weekly/monthly), priority levels, timezone support, and playlist overrides - **Scheduling** — visual weekly calendar with recurrence rules (daily/weekly/monthly), priority-based conflict resolution, both device-level and group-level schedules (device-level overrides win over group-level), timezone support
- **Content designer** — clocks, weather, RSS tickers, countdowns, QR codes - **Widgets** — clocks, weather, RSS tickers, text/HTML, webpages, social feeds, and Directory Board (scrolling lobby tenant/room/staff directories with dark/light themes, category management, and anti-burn-in motion)
- **Kiosk mode** — interactive touchscreen interfaces - **Kiosk mode** — interactive touchscreen interfaces
- **Proof-of-play** — per-content and per-device analytics, hourly/daily breakdowns, CSV export for ad verification - **Proof-of-play** — per-content and per-device analytics, hourly/daily breakdowns, CSV export for ad verification
- **Device telemetry** — battery, storage, RAM, CPU, WiFi signal strength, and uptime reported by Android players - **Device telemetry** — battery, storage, RAM, CPU, WiFi signal strength, and uptime reported by Android players
- **Alerts** — email notifications when devices go offline - **Offline resilience** — both web and Android players keep displaying cached content during server or internet outages (Android ContentCache, web player Service Worker); state syncs when connectivity returns
- **Teams** — multi-user with owner, editor, and viewer roles; team-based device access - **Mobile-responsive** — full management dashboard and landing page work on phones and tablets
- **Workspaces** — multi-tenant data model: organizations contain workspaces, workspaces contain devices/content/playlists/schedules; users can be members of multiple workspaces and switch via a dropdown in the sidebar
- **Member roles** — six-level hierarchy (platform_admin / org_owner / org_admin / workspace_admin / workspace_editor / workspace_viewer) gated at every API route
- **Alerts** — email notifications via Microsoft Graph when devices go offline; built-in spam protection (2h dedup, 24h long-offline cutoff, sequential send pattern); per-user opt-out via Settings → Account
- **White-label** — custom branding, colors, logo, favicon, CSS, and domain - **White-label** — custom branding, colors, logo, favicon, CSS, and domain
- **Content management** — folder organization, remote URL content (no upload needed), YouTube embeds, video duration detection via ffprobe, automatic thumbnail generation - **Content management** — folder organization, remote URL content (no upload needed), YouTube embeds, video duration detection via ffprobe, automatic thumbnail generation, Unicode-safe filenames (NFC normalization + UTF-8 multipart decoding)
- **Export/Import** — v2 format with playlists, device groups, schedules, and optional media bundling (ZIP); backward-compatible v1 import with automatic playlist migration - **Export/Import** — v2 format with playlists, device groups, schedules, and optional media bundling (ZIP); backward-compatible v1 import with automatic playlist migration
- **Device authentication** — per-device tokens for secure WebSocket connections; devices authenticate on every reconnect - **Device authentication** — per-device tokens for secure WebSocket connections; devices authenticate on every reconnect
- **Account management** — in-app password change, profile editing, email-based password reset
- **Security** — JWT auth, bcrypt hashing, parameterized SQL, rate-limited endpoints, per-user ownership checks on all resources, ongoing auth/IDOR/XSS audits
- **Built-in billing** — Stripe integration for SaaS subscriptions (optional) - **Built-in billing** — Stripe integration for SaaS subscriptions (optional)
- **Auto-update** — OTA updates pushed to devices automatically - **Auto-update** — OTA updates pushed to devices automatically
- **Activity log** — full audit trail of user and system actions - **Activity log** — full audit trail of user and system actions
## Architecture
### Multi-tenancy model
Three nested primitives:
```
organizations (billing + branding container)
workspaces (resource scope: devices, content, playlists, schedules, walls, layouts, widgets, groups)
members (users with a role on that workspace)
```
Every resource (device, content row, playlist, schedule, etc.) carries a `workspace_id`. Every API route filters by it. Cross-workspace access requires switching workspaces via the sidebar dropdown — there are no magic role-based "see everything" bypasses on individual resource routes.
### Role hierarchy
Six roles, top wins:
| Role | Scope | Cap |
|---|---|---|
| `platform_admin` | every workspace in the system | full read/write (via acting-as on workspaces they're not a direct member of) |
| `org_owner` | one organization | billing + delete + admin within all workspaces in the org |
| `org_admin` | one organization | admin within all workspaces in the org (no billing) |
| `workspace_admin` | one workspace | manage members, rename, full read/write |
| `workspace_editor` | one workspace | create/edit content, devices, playlists, schedules; no member changes |
| `workspace_viewer` | one workspace | read-only |
### Workspace switcher
Users who are members of more than one workspace see a dropdown in the sidebar header. Switching mints a fresh JWT with the new `current_workspace_id` claim and reloads the page. Platform admins see every workspace in the system.
### Auto-migration on boot
Schema migrations run automatically the first time the server starts after a git pull. **Self-hosters never need to run a manual migration command.** On detecting a pre-multi-tenancy database, the server takes a timestamped snapshot (`server/db/remote_display.pre-migration-<timestamp>.db`), runs the Phase 1 migration (creates `organizations` / `workspaces` / `workspace_members` tables, backfills `workspace_id` on every resource, one auto-created Default workspace per existing user), then continues startup. If the migration fails the server prints the restore command and exits.
### Data flow
- **Android / web players** → device-namespace WebSocket → server. Authenticated per-device with a long-lived device token. Each device joins a room keyed on its `device_id`.
- **Admin dashboard** → dashboard-namespace WebSocket → server. Authenticated with the user's JWT. Each socket joins one room per accessible workspace so outbound events (device status, screenshots, playback progress) only reach dashboards that should see them.
- **Admin REST**`/api/*` HTTPS → Express → SQLite. Everything scoped by `workspace_id` from JWT `current_workspace_id` claim.
- **Email** → Microsoft Graph `sendMail` via client-credentials OAuth flow. In-memory token cache. Sequential send pattern through alert backlogs to respect Graph's per-app concurrency limits.
## Supported Platforms ## Supported Platforms
Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, and any device with a web browser. Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, and any device with a web browser.
@ -34,8 +82,9 @@ Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, a
### Requirements ### Requirements
- Node.js 20+ - Node.js **20.6+** (the npm scripts use the built-in `--env-file-if-exists` flag, added in 20.6)
- Linux, macOS, or Windows - Linux, macOS, or Windows
- SQLite (bundled via `better-sqlite3`; no separate install needed — `npm install` handles the native bindings)
### Quick Start ### Quick Start
@ -43,27 +92,47 @@ Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, a
git clone https://github.com/screentinker/screentinker.git git clone https://github.com/screentinker/screentinker.git
cd screentinker/server cd screentinker/server
npm install npm install
SELF_HOSTED=true node server.js SELF_HOSTED=true npm start
``` ```
The server starts on port 3001 (HTTP). If SSL certificates are present in `server/certs/`, it starts on port 3443 (HTTPS) with automatic HTTP-to-HTTPS redirect. Open the URL shown in the startup banner. The first registered user gets full access with all features unlocked. The server starts on port 3001 (HTTP). If SSL certificates are present in `server/certs/`, it starts on port 3443 (HTTPS) with automatic HTTP-to-HTTPS redirect. Open the URL shown in the startup banner. The first registered user gets full access with all features unlocked.
Schema migrations run automatically on first boot — no manual migration commands at any point in the lifecycle.
`npm start` is preferred over `node server.js` directly because the script invokes Node with `--env-file-if-exists=.env` so a `server/.env` file (gitignored) is loaded automatically for local dev.
### Environment Variables ### Environment Variables
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `PORT` | HTTP port | `3001` | | `PORT` | HTTP port | `3001` |
| `HTTPS_PORT` | HTTPS port (used when SSL certs are present) | `3443` | | `HTTPS_PORT` | HTTPS port (used when SSL certs are present) | `3443` |
| `NODE_ENV` | Runtime env (`production` enables Express production optimizations + stricter error handling) | _(none)_ |
| `SELF_HOSTED` | First user gets all features unlocked | `false` | | `SELF_HOSTED` | First user gets all features unlocked | `false` |
| `APP_URL` | Your public URL (used for Stripe callbacks) | _(none)_ | | `DISABLE_REGISTRATION` | Block new account creation (including OAuth auto-signup). First-user setup on an empty DB is still allowed. | `false` |
| `DISABLE_HOMEPAGE` | Redirect `/` to `/app` instead of serving the marketing landing page. For internal-only self-hosted deployments. | `false` |
| `APP_URL` | Your public URL (used for Stripe callbacks and invite-accept URLs in emailed invites) | _(none)_ |
| `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ | | `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ |
| `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` | | `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` |
| `SSL_KEY` | Path to SSL private key | `server/certs/key.pem` | | `SSL_KEY` | Path to SSL private key | `server/certs/key.pem` |
| `PING_INTERVAL` | Socket.IO Engine.IO ping interval (ms). Raise for slow TV WebKits that miss pongs under decode load. | `30000` |
| `PING_TIMEOUT` | Socket.IO Engine.IO pong wait (ms). Lower = faster dead-socket detection; higher = more forgiving of laggy clients. | `30000` |
| `HEARTBEAT_INTERVAL` | App-level offline-checker frequency (ms). How often the server sweeps the device list looking for stale heartbeats. | `10000` |
| `HEARTBEAT_TIMEOUT` | How long without an app-level heartbeat (ms) before marking a device offline. Raise for slow/jittery networks. | `45000` |
| `COMMAND_QUEUE_TTL_MS` | How long the server holds commands and playlist-updates for a device that's offline at emit time (ms). Flushed in order on reconnect within this window; dropped past TTL. | `30000` |
### Optional Integrations ### Optional Integrations
All integrations are optional. The app works fully without any of them. All integrations are optional. The app works fully without any of them.
#### AI Content Design (local or cloud)
The Content Designer can turn a prompt into a finished sign — layout + copy from
an LLM, and optional background/foreground imagery from an image model. Each
workspace brings its own **OpenAI-compatible** endpoints (cloud, or fully local
and free via Ollama + stable-diffusion.cpp). See
**[docs/local-ai-setup.md](docs/local-ai-setup.md)**.
#### Stripe (Billing) #### Stripe (Billing)
If you want to charge your users, plug in your own Stripe keys. Without them, all features are free for all users. If you want to charge your users, plug in your own Stripe keys. Without them, all features are free for all users.
@ -115,15 +184,42 @@ Let users sign in with Microsoft/Azure AD.
| `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID | | `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID |
| `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) | | `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) |
#### Email Alerts #### Email Alerts (Microsoft Graph)
Send email notifications when devices go offline. Send email notifications when devices go offline. Backed by Microsoft Graph Mail.Send via the client-credentials flow.
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
| `EMAIL_WEBHOOK_URL` | POST endpoint that sends emails. Receives JSON: `{ to, subject, body }` | | `GRAPH_TENANT_ID` | Microsoft Azure AD tenant ID |
| `GRAPH_CLIENT_ID` | Azure AD app registration client ID |
| `GRAPH_CLIENT_SECRET` | Azure AD app registration client secret |
| `GRAPH_SENDER_EMAIL` | Mailbox to send from (must be a valid mailbox or alias in the tenant) |
| `GRAPH_SENDER_NAME` | Display name shown in the email `From` field (defaults to `ScreenTinker`) |
You can point this at any email sending service (SendGrid, Mailgun, a simple SMTP relay, etc.) via a small webhook adapter. **Azure AD app setup:**
1. Register a new app in Azure AD (single-tenant)
2. Under **API permissions**, add an **Application** permission: Microsoft Graph → `Mail.Send`
3. Click **Grant admin consent** for the tenant
4. Under **Certificates & secrets**, generate a new **Client secret** and capture the value (it is only shown once)
5. Capture the **Directory (tenant) ID** and **Application (client) ID** from the Overview page
6. Set the five env vars above in your deployment (systemd unit, `.env` file, etc.)
**Local dev fallback:** if any of `GRAPH_TENANT_ID`, `GRAPH_CLIENT_ID`, `GRAPH_CLIENT_SECRET`, or `GRAPH_SENDER_EMAIL` is unset, `sendEmail()` short-circuits and logs `[EMAIL] not configured - would send to ...` to stdout instead of calling Graph. The app keeps running normally; only delivery is suppressed. This means a minimal local-dev install with no M365 access works fine — email-triggering features (device-offline alerts, future invite emails) just won't deliver anything externally.
**Dev safety allow-list:**
| Variable | Description |
|----------|-------------|
| `GRAPH_DEV_RESTRICT_TO` | Comma-separated allow-list of recipient emails. When set, sends to addresses **not** in the list are suppressed (logged but never posted to Graph). |
Use this in local dev when running against a fresh production database clone to prevent accidental emails to real users. Leave it **unset in production** so emails flow to everyone normally.
**Alert spam protections** (also live, no configuration needed):
- **2-hour dedup window** per (alert-type, target-id) pair — the same device won't trigger repeated alerts within two hours
- **24-hour long-offline cutoff** — devices that have been offline for more than 24 hours stop generating alerts (the user already knows or the device is abandoned; further alerts are noise)
- **Sequential send pattern** through the offline-alert backlog — avoids Graph's per-app concurrent-send throttling (HTTP 429 `ApplicationThrottled`)
- **Per-user opt-out** via the `email_alerts` toggle in Settings → Account; respects user preference before any Graph call
### Production Deployment ### Production Deployment
@ -155,9 +251,22 @@ Restart=always
Environment=PORT=3001 Environment=PORT=3001
Environment=NODE_ENV=production Environment=NODE_ENV=production
Environment=SELF_HOSTED=true Environment=SELF_HOSTED=true
# Lock down an internal / provisioned-only instance (all accounts created by your
# team). DISABLE_REGISTRATION closes self-service signup — first-user setup on an
# empty DB is still allowed, and the login page hides its "Create account" button
# to match. DISABLE_HOMEPAGE sends `/` straight to the app instead of the
# marketing landing page.
# Environment=DISABLE_REGISTRATION=true
# Environment=DISABLE_HOMEPAGE=true
# Environment=APP_URL=https://signage.yourcompany.com # Environment=APP_URL=https://signage.yourcompany.com
# Environment=STRIPE_SECRET_KEY=sk_live_... # Environment=STRIPE_SECRET_KEY=sk_live_...
# Environment=STRIPE_WEBHOOK_SECRET=whsec_... # Environment=STRIPE_WEBHOOK_SECRET=whsec_...
# Email alerts via Microsoft Graph - see Email Alerts section above for setup
# Environment=GRAPH_TENANT_ID=...
# Environment=GRAPH_CLIENT_ID=...
# Environment=GRAPH_CLIENT_SECRET=...
# Environment=GRAPH_SENDER_EMAIL=support@yourcompany.com
# Environment=GRAPH_SENDER_NAME=Your Brand
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@ -205,43 +314,64 @@ To update a running instance to the latest version:
```bash ```bash
cd /opt/screentinker cd /opt/screentinker
# Back up the database first # Upgrade to the latest tagged release. Backs up the db (a .backup snapshot under
sqlite3 server/db/remote_display.db ".backup server/db/backup-$(date +%F).db" # ./backups), checks out the tag, runs npm ci --omit=dev, restarts the service,
# and reports the running version.
scripts/upgrade.sh
# Pull latest code # ...or pin a specific release:
git pull origin main scripts/upgrade.sh v1.8.0
# Install any new dependencies
cd server && npm install --production
# Restart the service
sudo systemctl restart screentinker
``` ```
If you deployed without git, you can initialize it: Set `SERVICE_NAME` if your systemd unit is not named `screentinker`.
If you deployed without git, initialize it once so `upgrade.sh` can resolve tags:
```bash ```bash
cd /opt/screentinker cd /opt/screentinker
git init git init
git remote add origin https://github.com/screentinker/screentinker.git git remote add origin https://github.com/screentinker/screentinker.git
git fetch origin main git fetch origin --tags
git checkout origin/main -- . git checkout -f main
cd server && npm install --production cd server && npm install --production
sudo systemctl restart screentinker sudo systemctl restart screentinker
``` ```
**Track bleeding edge (`main`)** instead of tagged releases - newest code, less tested:
```bash
cd /opt/screentinker && git checkout main && git pull origin main
cd server && npm install --production && sudo systemctl restart screentinker
```
Your database, uploads, and configuration are preserved — only code files are updated. Your database, uploads, and configuration are preserved — only code files are updated.
**Schema migrations run automatically.** No manual migration commands at any point. On detecting a database that hasn't been through Phase 1 multi-tenancy migration yet, the server takes a timestamped snapshot first (`server/db/remote_display.pre-migration-<timestamp>.db`) and only continues startup once migration commits cleanly. If migration fails, the server logs the snapshot's path and exits — restore it with `cp` and investigate before retrying.
### Backups ### Backups
The SQLite database is at `server/db/remote_display.db`. Back it up regularly: The SQLite database is at `server/db/remote_display.db` and uploaded content is in
`server/uploads/`. For a one-off DB copy (safe while the server runs):
```bash ```bash
# Safe backup (works even while the server is running)
sqlite3 server/db/remote_display.db ".backup /path/to/backup.db" sqlite3 server/db/remote_display.db ".backup /path/to/backup.db"
``` ```
Uploaded content is in `server/uploads/`. Back that up too. **Recommended: nightly automated backups** via `scripts/backup.sh`. It takes an
atomic DB snapshot plus a hard-linked, point-in-time copy of your content (durable
images/videos; ephemeral per-device screenshots are excluded), with daily + monthly
retention and an error log. Add a cron entry:
```bash
# as root (or your service user) — adjust the path to your install
0 3 * * * /opt/screentinker/scripts/backup.sh
```
Override defaults with env vars if your layout differs:
`SCREENTINKER_DIR` (default `/opt/screentinker`), `BACKUP_DIR`, `DB`, `UPLOADS`,
`DAILY_KEEP` (7), `MONTHLY_KEEP` (12), `DB_KEEP_DAYS` (30). Backups land in
`$BACKUP_DIR` (`remote_display-<ts>.db`, `content-latest/`, `content-<ts>/`,
`content-monthly-<YYYYMM>/`) and each run appends to `$BACKUP_DIR/backup.log`.
### Admin Recovery ### Admin Recovery
@ -273,6 +403,13 @@ The APK will be at `android/app/build/outputs/apk/debug/app-debug.apk`. Copy it
cp android/app/build/outputs/apk/debug/app-debug.apk ScreenTinker.apk cp android/app/build/outputs/apk/debug/app-debug.apk ScreenTinker.apk
``` ```
> **Release builds & MDM signage (#81):** `./gradlew assembleRelease` is automatically
> re-signed to carry a **v1 (JAR) signature alongside v2/v3** (the `resignReleaseV1` task in
> `app/build.gradle.kts`). At `minSdk 26` the Gradle plugin omits v1, and some MDM-managed
> commercial displays (e.g. MAXHUB/Pivot) **strip a v2-only APK on reboot** — screens that
> power-cycle nightly then lose the app. v1+v2+v3 installs everywhere from API 19 to the
> latest Android. (`enableV1Signing = true` alone does not work at minSdk ≥ 24.)
To generate a new signing keystore: To generate a new signing keystore:
```bash ```bash
@ -289,9 +426,49 @@ keytool -genkey -v -keystore android/release-key.jks -keyalg RSA -keysize 2048 -
- **Android TV / tablets**: Download the APK from your instance (`/download/apk`) or build it from source (see above) - **Android TV / tablets**: Download the APK from your instance (`/download/apk`) or build it from source (see above)
- **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | bash` - **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | bash`
- **Windows**: Run the setup script from `scripts/windows-setup.bat` - **Windows**: Run the setup script from `scripts/windows-setup.bat`
- **Samsung Tizen TV / signage**: point the TV's URL Launcher (or browser) at `https://your-instance/player` - no signing needed. For an installed native app, see [tizen/README.md](tizen/README.md)
- **Any browser**: Open `https://your-instance/player` in kiosk/fullscreen mode - **Any browser**: Open `https://your-instance/player` in kiosk/fullscreen mode
4. Enter the pairing code shown on the device 4. Enter the pairing code shown on the device
> **Troubleshooting a player** (stuck on "Connecting to server", re-pointing a
> device to a different server, or connecting adb over Wi-Fi): see
> [docs/android-troubleshooting.md](docs/android-troubleshooting.md).
### For Developers
Working on ScreenTinker itself:
```bash
git clone https://github.com/screentinker/screentinker.git
cd screentinker/server
npm install
npm start # starts in dev with --env-file-if-exists=.env
# or:
npm run dev # same as start, plus --watch for auto-restart
```
**`.env` file (gitignored):** create `server/.env` for local configuration. Anything documented in the env var tables above works. Common starting set:
```
SELF_HOSTED=true
APP_URL=https://localhost:3443
# Optional: Microsoft Graph email config for testing real delivery
# GRAPH_TENANT_ID=...
# GRAPH_CLIENT_ID=...
# GRAPH_CLIENT_SECRET=...
# GRAPH_SENDER_EMAIL=you@yourcompany.com
# Optional: dev safety - only let these recipient emails through to Graph
# GRAPH_DEV_RESTRICT_TO=you@yourcompany.com,colleague@yourcompany.com
```
**No M365 access?** That's fine. With `GRAPH_*` env vars unset, `sendEmail()` short-circuits and logs `[EMAIL] not configured - would send to ...` to stdout. Everything else runs normally; only outbound email is suppressed. Useful for backend work that touches the email path without setting up an Azure app.
**Running against a fresh prod DB clone?** Set `GRAPH_DEV_RESTRICT_TO=your-email@example.com` to keep accidental sends from reaching real users in the cloned database. Sends to anyone outside the list are logged but never posted to Graph.
**Reporting issues:** [GitHub Issues](https://github.com/screentinker/screentinker/issues) for bugs and feature requests, or drop into [Discord](https://discord.gg/utTdsrqq4Z) for quick questions and feedback.
**Contributions welcome.** Fork → branch → PR. There are no formal style guides yet beyond what you can pick up from reading the existing code. Tests aren't required but smoke-test against your local server before opening a PR.
## Project Structure ## Project Structure
``` ```
@ -315,11 +492,25 @@ scripts/ Device setup scripts + admin recovery
## Tech Stack ## Tech Stack
- **Backend:** Node.js, Express, Socket.IO, SQLite (better-sqlite3) - **Backend:** Node.js 20.6+, Express, Socket.IO, SQLite (better-sqlite3)
- **Frontend:** Vanilla JS SPA (no framework, no build step) - **Frontend:** Vanilla JS SPA (no framework, no build step), ES modules, Service Worker for offline support
- **Android:** Kotlin, ExoPlayer, Socket.IO client - **Android:** Kotlin, ExoPlayer, Socket.IO client
- **Auth:** JWT with bcrypt, Google/Microsoft OAuth (optional) - **Auth:** JWT with bcrypt, Google/Microsoft OAuth (optional)
- **Email:** Microsoft Graph via `@azure/msal-node` client-credentials (optional)
- **Payments:** Stripe (optional) - **Payments:** Stripe (optional)
- **Data model:** multi-tenant — organizations contain workspaces contain resources; six-level role hierarchy gated server-side at every API route
## Support
ScreenTinker is built and maintained by one developer. If the project is useful to you and you want to support continued development:
- **[Donate via Wise](https://wise.com/pay/business/bytetinkerllc?utm_source=quick_pay)** — directly help fund continued development (ByteTinker LLC)
- Star the repo on GitHub
- Open [issues](https://github.com/screentinker/screentinker/issues) with feedback or bug reports
- Drop into the [Discord](https://discord.gg/utTdsrqq4Z) and say hi
- Contribute back if you've extended something useful
GitHub Sponsors integration is also planned. Direct contact: [dan@bytetinker.net](mailto:dan@bytetinker.net) or via Discord.
## License ## License

64
RELEASING.md Normal file
View file

@ -0,0 +1,64 @@
# Releasing ScreenTinker
`VERSION` (repo root) is the single source of truth the server reports at runtime.
Cutting a release is three steps.
## 1. Bump + tag
```bash
scripts/bump-version.sh X.Y.Z # or: major | minor | patch
```
Syncs `VERSION`, `server/package.json` (+lockfile), the android `versionName` /
`versionCode`, and the tizen widget version in one commit, then creates an
annotated tag `vX.Y.Z`. It does NOT push - it prints the push command. Requires a
clean working tree.
## 2. Push (this publishes the release)
```bash
git push origin main && git push origin vX.Y.Z
```
Pushing the tag fires `.github/workflows/release.yml`:
- **verify** - refuses to publish if the tag does not match `VERSION`.
- **test** - the unit suite.
- **artifacts** - builds the source tarball (bundling the unsigned Tizen `.wgt`)
and creates the GitHub Release with generated notes.
- **docker** - builds a multi-arch (amd64 + arm64) image and pushes
`ghcr.io/screentinker/screentinker:X.Y.Z` and `:latest`.
`artifacts` and `docker` are independent jobs: a docker (arm64/QEMU) failure does
not block the GitHub Release and can be re-run on its own. Nothing here deploys to
production.
## 3. Finalize (adds the signed APK)
The Android signing keystore stays off CI, so the signed apk and the complete
(apk + wgt) tarball are assembled locally, then uploaded to the release:
```bash
KEYSTORE_PASSWORD=... KEY_PASSWORD=... scripts/finalize-release.sh
```
It builds the signed APK, pulls the CI-built unsigned `.wgt` back from the
release, assembles a complete tarball (source + `ScreenTinker.apk` +
`ScreenTinker.wgt` at the root, where `/download/apk` resolves the apk after
extraction), and uploads the apk + complete tarball.
## What a release contains
Each release carries these as standalone assets AND bundled in the tarball:
- `screentinker-X.Y.Z.tar.gz` - server + frontend source + apk + wgt at the root
- `ScreenTinker.apk` - signed Android player
- `ScreenTinker.wgt` - Tizen TV web app (unsigned; see [tizen/README.md](tizen/README.md))
- `ghcr.io/screentinker/screentinker:X.Y.Z` + `:latest` - Docker image
## One-time / occasional
- **ghcr visibility:** new packages default to private. Set the package Public
once (Repo -> Packages -> `screentinker` -> Package settings -> Change
visibility -> Public) so anonymous `docker pull` works.
- **Self-hosters upgrade** with `scripts/upgrade.sh [vX.Y.Z]` (see the README).

97
SECURITY.md Normal file
View file

@ -0,0 +1,97 @@
# Security Policy
Thanks for taking the time to look at ScreenTinker's security. The project
is a one-person open-source effort, so response times reflect that — but
reports are taken seriously and handled in good faith.
## Reporting a vulnerability
**Primary channel — GitHub Security Advisories (preferred):**
[github.com/screentinker/screentinker/security/advisories/new](https://github.com/screentinker/screentinker/security/advisories/new)
GitHub's private advisory flow keeps the report off public issues, lets us
draft a fix collaboratively, and produces a CVE if appropriate. Use this
unless you have a reason not to.
**Fallback — email:**
`support@bytetinker.net` (the maintainer's consultancy inbox; the domain
intentionally differs from `screentinker.com` — it's the actively-monitored
business address rather than a project-domain alias that might not have
working mail delivery).
Please include:
- A description of the issue and its impact
- Steps to reproduce (the more concrete, the better)
- The commit SHA or release tag you observed it on
- Any proof-of-concept code or payload, if you have one
## Response timeline
I aim to acknowledge reports within **35 business days** and update with a
triage assessment within **10 business days**. If you haven't heard back
in that window, please feel free to nudge — life happens, and reports
occasionally slip past.
Fix timelines depend on severity, complexity, and whether the issue is on
the hosted instance (screentinker.com) or affects self-hosted deployments
too. Critical issues affecting the hosted instance generally get same-week
turnaround.
## In scope
Reports about the following are welcome and treated as security issues:
- **Authentication / session bypass** (e.g. JWT forgery, login bypass,
privilege escalation)
- **Multi-tenancy boundary violations** (one workspace's data leaking into
another, organization-level isolation breaks)
- **XSS in widget rendering or admin UI** (e.g. unsandboxed widget content,
unescaped user input in dashboard surfaces)
- **CSRF** on state-changing endpoints
- **SQL injection** (deviations from parameterized queries are reportable)
- **Server-side request forgery** (SSRF) via widget URLs, content uploads,
webhook handlers, or similar
- **Insecure direct object reference** (accessing a resource by ID without
the proper tenancy gate)
## Out of scope
The following are acknowledged but not treated as in-scope security
issues for this project:
- **Denial of service via excessive resource usage** (uploading large
files, opening many sockets, etc.) — operational concerns, not security
vulnerabilities. Rate limits exist where it matters most.
- **Social engineering** of the maintainer or other users
- **Misconfigurations of self-hosted instances** (e.g. exposing the server
to the internet without TLS, weak JWT secrets, default passwords). The
README documents recommended configuration; deviations are the operator's
responsibility.
- **Vulnerabilities in third-party dependencies** (Express, better-sqlite3,
socket.io, etc.) — please report those upstream. If a dependency CVE
affects ScreenTinker in a non-obvious way, that's worth flagging here too.
- **Reports generated by automated scanners** with no manual triage or
proof-of-concept (e.g. "your /robots.txt is missing" — not what this
project worries about)
## Coordinated disclosure
Please **wait until a fix has shipped to the hosted instance and
origin/main before public disclosure**. I'll keep you in the loop on
timing and confirm when it's safe to publish. For most issues that
window is a few weeks at most; if it stretches longer, that's a signal
something is more complex than expected and we'll coordinate.
If you find a critical issue that's being actively exploited (or you
believe might be), please say so in the report — I'll prioritize
accordingly.
## Acknowledgments
If you'd like to be credited for a report, I'm happy to acknowledge you
by name in release notes and (when applicable) in the GitHub advisory
itself. Let me know in your report whether you'd like credit and how
you'd like to be named. Anonymous reports are also welcome — no credit
is required.

View file

@ -1 +1 @@
1.7.7 1.9.1-beta1

View file

@ -11,8 +11,8 @@ android {
applicationId = "com.remotedisplay.player" applicationId = "com.remotedisplay.player"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 10 versionCode = 21
versionName = "1.7.7" versionName = "1.9.1-beta1"
} }
signingConfigs { signingConfigs {
@ -21,6 +21,9 @@ android {
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: findProperty("KEYSTORE_PASSWORD") as String? ?: "" storePassword = System.getenv("KEYSTORE_PASSWORD") ?: findProperty("KEYSTORE_PASSWORD") as String? ?: ""
keyAlias = System.getenv("KEY_ALIAS") ?: findProperty("KEY_ALIAS") as String? ?: "remotedisplay" keyAlias = System.getenv("KEY_ALIAS") ?: findProperty("KEY_ALIAS") as String? ?: "remotedisplay"
keyPassword = System.getenv("KEY_PASSWORD") ?: findProperty("KEY_PASSWORD") as String? ?: "" keyPassword = System.getenv("KEY_PASSWORD") ?: findProperty("KEY_PASSWORD") as String? ?: ""
// #81: AGP ignores enableV1Signing at minSdk>=24, so assembleRelease emits a
// v2-only APK. The v1 (JAR) signature that some MDM-managed signage (MAXHUB)
// requires is added by the `resignReleaseV1` task below (apksigner re-sign).
} }
} }
@ -75,4 +78,50 @@ dependencies {
// Coroutines // Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// #74/#75: unit tests for the Kotlin schedule evaluator (vector drift guard)
testImplementation("junit:junit:4.13.2")
} }
// #74/#75: point the evaluator drift-guard test at the SHARED vector contract
// (shared/schedule-vectors.json, the single source - no snapshot). rootProject is
// the android/ Gradle root; its parent is the repo root. Any ScheduleEval.kt edit
// that breaks a vector fails ScheduleEvalTest in CI.
tasks.withType<Test> {
systemProperty("scheduleVectors", File(rootProject.projectDir.parentFile, "shared/schedule-vectors.json").absolutePath)
}
// #81: AGP ignores enableV1Signing at minSdk>=24, so `assembleRelease` produces a
// v2-only APK - and some MDM-managed signage (MAXHUB/Pivot) silently removes a v2-only
// app on the next reboot because its boot integrity check expects a v1 (JAR) signature.
// Re-sign the assembled release APK with apksigner, forcing a low --min-sdk-version so
// the v1 signature is emitted alongside v2/v3. v1+v2+v3 verifies on every Android
// version (legacy MDM hardware via v1, modern Android via v2/v3).
tasks.register<Exec>("resignReleaseV1") {
val apk = layout.buildDirectory.file("outputs/apk/release/app-release.apk").get().asFile
onlyIf { apk.exists() }
doFirst {
val sdkDir = System.getenv("ANDROID_HOME")
?: System.getenv("ANDROID_SDK_ROOT")
?: rootProject.file("local.properties").takeIf { it.exists() }
?.readLines()?.firstOrNull { it.startsWith("sdk.dir=") }?.substringAfter("=")?.trim()
?: throw GradleException("#81 resign: set ANDROID_HOME or sdk.dir in local.properties")
val buildTools = File(sdkDir, "build-tools").listFiles()
?.filter { it.isDirectory }?.maxByOrNull { it.name }
?: throw GradleException("#81 resign: no build-tools found under $sdkDir")
commandLine(
File(buildTools, "apksigner").absolutePath, "sign",
"--ks", file("../release-key.jks").absolutePath,
"--ks-key-alias", (System.getenv("KEY_ALIAS") ?: "remotedisplay"),
"--ks-pass", "pass:" + (System.getenv("KEYSTORE_PASSWORD") ?: ""),
"--key-pass", "pass:" + (System.getenv("KEY_PASSWORD") ?: ""),
"--v1-signing-enabled", "true",
"--v2-signing-enabled", "true",
"--v3-signing-enabled", "true",
"--min-sdk-version", "19",
apk.absolutePath
)
}
}
// AGP registers assembleRelease lazily, so match it when/after it's created.
tasks.matching { it.name == "assembleRelease" }.configureEach { finalizedBy("resignReleaseV1") }

View file

@ -14,12 +14,15 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application <application
android:name=".RemoteDisplayApp" android:name=".RemoteDisplayApp"
android:allowBackup="true" android:allowBackup="false"
android:icon="@android:drawable/ic_media_play" android:icon="@android:drawable/ic_media_play"
android:label="RemoteDisplay" android:label="RemoteDisplay"
android:largeHeap="true"
android:theme="@style/Theme.RemoteDisplay" android:theme="@style/Theme.RemoteDisplay"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:supportsRtl="true"> android:supportsRtl="true">
@ -66,11 +69,21 @@
android:screenOrientation="landscape" android:screenOrientation="landscape"
android:theme="@style/Theme.RemoteDisplay.Fullscreen" /> android:theme="@style/Theme.RemoteDisplay.Fullscreen" />
<!-- WebSocket foreground service --> <!-- WebSocket foreground service. #5: declares ONLY mediaPlayback - the
always-on service must not claim the mediaProjection FGS type, which
Android 14+ rejects unless a projection consent token is held. -->
<service <service
android:name=".service.WebSocketService" android:name=".service.WebSocketService"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaPlayback|mediaProjection" /> android:foregroundServiceType="mediaPlayback" />
<!-- #5: dedicated MediaProjection foreground service for system screen
capture. Started only after the user grants consent, so claiming the
mediaProjection FGS type is valid on Android 14+. -->
<service
android:name=".service.MediaProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<!-- Accessibility service for power controls --> <!-- Accessibility service for power controls -->
<service <service
@ -97,6 +110,18 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- #96: relaunch the player after a self-update (OTA). MY_PACKAGE_REPLACED is
delivered to the freshly-installed app; the receiver relaunches via the same
cascade as boot so the screen doesn't drop to the launcher after an update. -->
<receiver
android:name=".service.PackageReplacedReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- FileProvider for APK updates --> <!-- FileProvider for APK updates -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"

View file

@ -52,6 +52,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var statusOverlay: View private lateinit var statusOverlay: View
private lateinit var statusText: TextView private lateinit var statusText: TextView
private lateinit var rootView: View private lateinit var rootView: View
private var currentOrientation: String? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var remoteStreaming = false private var remoteStreaming = false
@ -100,6 +101,9 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
// The display is up now — clear the boot "Starting display…" notification.
(getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager)?.cancel(999)
// Fullscreen immersive // Fullscreen immersive
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
window.decorView.systemUiVisibility = ( window.decorView.systemUiVisibility = (
@ -133,8 +137,10 @@ class MainActivity : AppCompatActivity() {
// Setup playlist controller // Setup playlist controller
playlistController = PlaylistController( playlistController = PlaylistController(
onItemChanged = { item -> item?.let { playItem(it) } }, onItemChanged = { item -> item?.let { playItem(it) } },
onPlaylistEmpty = { showStatus("Waiting for content...") }, // #74/#75: clear the last frame when going idle (else a now-filtered item lingers on screen)
onRequestRefresh = { wsService?.requestPlaylistRefresh() } onPlaylistEmpty = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) },
onRequestRefresh = { wsService?.requestPlaylistRefresh() },
onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) }
) )
// Setup media player // Setup media player
@ -144,10 +150,38 @@ class MainActivity : AppCompatActivity() {
playerView = playerView, playerView = playerView,
imageView = imageView, imageView = imageView,
youtubeWebView = youtubeWebView, youtubeWebView = youtubeWebView,
onVideoComplete = { playlistController.onVideoComplete() } onVideoComplete = { playlistController.onVideoComplete() },
onImageError = {
Log.w("MainActivity", "Image failed to load, skipping to next item")
handler.postDelayed({ playlistController.next() }, 500)
}
) )
// Restore cached playlist for offline cold-start (play immediately from disk cache).
// Catch Throwable (not just Exception) so an OOM or corrupt entry can't kill the app
// before the WebSocket connects — that's the crash-loop scenario. If the cache is
// unusable for any reason, drop it and continue; the server will resend on connect.
val cachedJson = config.cachedPlaylist
if (cachedJson.isNotEmpty()) {
try {
val cached = JSONObject(cachedJson)
val assignments = cached.getJSONArray("assignments")
if (assignments.length() > 0) {
Log.i("MainActivity", "Restoring cached playlist: ${assignments.length()} items")
// #74/#75: restore the cached effective timezone too (offline schedules)
playlistController.setTimezone(if (cached.isNull("timezone")) null else cached.optString("timezone", "").ifEmpty { null })
playlistController.updatePlaylist(assignments)
playlistController.startIfNeeded()
}
} catch (e: Throwable) {
Log.w("MainActivity", "Failed to restore cached playlist, clearing cache: ${e.message}")
try { config.clearPlaylistCache() } catch (_: Throwable) {}
}
}
if (!playlistController.isPlaying) {
showStatus("Connecting to server...") showStatus("Connecting to server...")
}
// Start and bind to WebSocket service // Start and bind to WebSocket service
try { try {
@ -169,9 +203,40 @@ class MainActivity : AppCompatActivity() {
} }
// Rotate the whole stage in software so portrait / flipped signage works even on
// fixed-landscape hardware (Fire TV, Android TV and most signage sticks ignore
// setRequestedOrientation - they can't physically rotate the panel). Resizes
// rootView to the rotated dimensions, recenters, and rotates. Covers single-zone
// (playerView/imageView/youtubeWebView) and multi-zone (ZoneManager renders into
// the same rootView). Values mirror the dashboard: landscape / portrait /
// landscape-flipped / portrait-flipped.
private fun applyOrientation(orientation: String) {
if (orientation == currentOrientation) return
currentOrientation = orientation
val m = resources.displayMetrics
val w = m.widthPixels.toFloat()
val h = m.heightPixels.toFloat()
val (rot, swap) = when (orientation) {
"portrait" -> 90f to true
"portrait-flipped" -> 270f to true
"landscape-flipped" -> 180f to false
else -> 0f to false // landscape
}
val lp = rootView.layoutParams
lp.width = (if (swap) h else w).toInt()
lp.height = (if (swap) w else h).toInt()
rootView.layoutParams = lp
rootView.translationX = if (swap) (w - h) / 2f else 0f
rootView.translationY = if (swap) (h - w) / 2f else 0f
rootView.rotation = rot
rootView.requestLayout()
Log.i("MainActivity", "Applied orientation: $orientation (rotation=$rot, swap=$swap)")
}
private fun setupServiceCallbacks() { private fun setupServiceCallbacks() {
wsService?.onPlaylistUpdate = { data -> wsService?.onPlaylistUpdate = { data ->
try { try {
applyOrientation(data.optString("orientation", "landscape"))
// Check if device is suspended (trial expired / over limit) // Check if device is suspended (trial expired / over limit)
if (data.optBoolean("suspended", false)) { if (data.optBoolean("suspended", false)) {
val message = data.optString("message", "Account Suspended") val message = data.optString("message", "Account Suspended")
@ -184,6 +249,14 @@ class MainActivity : AppCompatActivity() {
val assignments = data.getJSONArray("assignments") val assignments = data.getJSONArray("assignments")
// #74/#75: device-effective IANA timezone for per-item schedule evaluation
val effectiveTz = if (data.isNull("timezone")) null else data.optString("timezone", "").ifEmpty { null }
playlistController.setTimezone(effectiveTz)
zoneManager?.setTimezone(effectiveTz)
// Cache playlist JSON for offline cold-start
config.cachedPlaylist = data.toString()
// Check for multi-zone layout // Check for multi-zone layout
val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout") val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout")
val layoutZones = layoutObj?.optJSONArray("zones") val layoutZones = layoutObj?.optJSONArray("zones")
@ -200,6 +273,7 @@ class MainActivity : AppCompatActivity() {
}.sorted().joinToString("|") }.sorted().joinToString("|")
val changed = assignmentSig != zoneManager?.lastAssignmentSig val changed = assignmentSig != zoneManager?.lastAssignmentSig
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: MULTI-ZONE (${layoutZones.length()} zones, layout=$layoutId), ${assignments.length()} assignments")
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) { if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) {
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)") Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
handler.post { handler.post {
@ -223,6 +297,7 @@ class MainActivity : AppCompatActivity() {
} }
} else { } else {
// Single-zone mode - use PlaylistController (existing behavior) // Single-zone mode - use PlaylistController (existing behavior)
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: SINGLE/FULLSCREEN (${layoutZones?.length() ?: 0} zones), ${assignments.length()} assignments")
if (zoneManager?.hasZones() == true) handler.post { zoneManager?.cleanup() } if (zoneManager?.hasZones() == true) handler.post { zoneManager?.cleanup() }
playlistController.updatePlaylist(assignments) playlistController.updatePlaylist(assignments)
} }
@ -231,7 +306,12 @@ class MainActivity : AppCompatActivity() {
thread { thread {
for (i in 0 until assignments.length()) { for (i in 0 until assignments.length()) {
val item = assignments.getJSONObject(i) val item = assignments.getJSONObject(i)
val contentId = item.getString("content_id") // Widget assignments have no downloadable content file - skip
// (also avoids getString throwing on a null content_id).
val widgetId = if (item.isNull("widget_id")) "" else item.optString("widget_id", "")
if (widgetId.isNotEmpty()) continue
val contentId = if (item.isNull("content_id")) "" else item.optString("content_id", "")
if (contentId.isEmpty()) continue
val filename = item.optString("filename", "content") val filename = item.optString("filename", "content")
val remoteUrl = item.optString("remote_url", null) val remoteUrl = item.optString("remote_url", null)
@ -258,9 +338,12 @@ class MainActivity : AppCompatActivity() {
} }
} }
// Start or resume playback after downloads complete // Start or resume playback after downloads complete — but ONLY in
// single-zone/fullscreen mode. In multi-zone, ZoneManager drives each
// zone; restarting the fullscreen controller here made it keep playing
// items behind the zones (wasted work + phantom audio for videos).
handler.post { handler.post {
playlistController.startIfNeeded() if (zoneManager?.hasZones() != true) playlistController.startIfNeeded()
} }
} }
} // end else (not suspended) } // end else (not suspended)
@ -272,6 +355,20 @@ class MainActivity : AppCompatActivity() {
wsService?.onContentDelete = { contentId -> wsService?.onContentDelete = { contentId ->
contentCache.deleteContent(contentId) contentCache.deleteContent(contentId)
playlistController.removeContent(contentId) playlistController.removeContent(contentId)
// Update cached playlist to reflect deletion
try {
val cached = JSONObject(config.cachedPlaylist)
val arr = cached.optJSONArray("assignments")
if (arr != null) {
val filtered = org.json.JSONArray()
for (i in 0 until arr.length()) {
val item = arr.getJSONObject(i)
if (item.optString("content_id") != contentId) filtered.put(item)
}
cached.put("assignments", filtered)
config.cachedPlaylist = cached.toString()
}
} catch (_: Exception) {}
} }
wsService?.onScreenshotRequest = { wsService?.onScreenshotRequest = {
@ -360,6 +457,7 @@ class MainActivity : AppCompatActivity() {
wsService?.onUnpaired = { wsService?.onUnpaired = {
Log.w("MainActivity", "Device removed from server, going to provisioning") Log.w("MainActivity", "Device removed from server, going to provisioning")
config.clearPlaylistCache()
handler.post { handler.post {
startActivity(Intent(this, ProvisioningActivity::class.java).apply { startActivity(Intent(this, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
@ -371,6 +469,18 @@ class MainActivity : AppCompatActivity() {
private fun playItem(item: PlaylistItem) { private fun playItem(item: PlaylistItem) {
hideStatus() hideStatus()
com.remotedisplay.player.util.DebugLog.i("Player", "playItem: ${item.filename} mime=${item.mimeType} widget=${item.widgetId ?: "-"} zone=fullscreen")
// Widget content - render fullscreen in a WebView (single-zone / fullscreen
// layouts; multi-zone widgets go through ZoneManager). Previously unhandled,
// so widgets were blank/broken in default-fullscreen and the fullscreen template.
if (item.isWidget) {
val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render"
Log.i("MainActivity", "Playing widget fullscreen: $url")
mediaPlayer.showWidget(url)
wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f)
return
}
// YouTube content - play in WebView // YouTube content - play in WebView
if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) { if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) {

View file

@ -34,6 +34,7 @@ class ProvisioningActivity : AppCompatActivity() {
private lateinit var statusText: TextView private lateinit var statusText: TextView
private lateinit var progressBar: ProgressBar private lateinit var progressBar: ProgressBar
private lateinit var pairingSection: View private lateinit var pairingSection: View
private lateinit var serverSection: View
private val connection = object : ServiceConnection { private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
@ -73,6 +74,7 @@ class ProvisioningActivity : AppCompatActivity() {
statusText = findViewById(R.id.statusText) statusText = findViewById(R.id.statusText)
progressBar = findViewById(R.id.progressBar) progressBar = findViewById(R.id.progressBar)
pairingSection = findViewById(R.id.pairingSection) pairingSection = findViewById(R.id.pairingSection)
serverSection = findViewById(R.id.serverSection)
// Pre-fill if previously entered // Pre-fill if previously entered
if (config.serverUrl.isNotEmpty()) { if (config.serverUrl.isNotEmpty()) {
@ -135,9 +137,15 @@ class ProvisioningActivity : AppCompatActivity() {
wsService?.onRegistered = { deviceId -> wsService?.onRegistered = { deviceId ->
runOnUiThread { runOnUiThread {
progressBar.visibility = View.GONE progressBar.visibility = View.GONE
// Hide the server/connect controls so the pairing code has the
// whole screen and stays visible on short/landscape phones.
serverSection.visibility = View.GONE
connectBtn.visibility = View.GONE
pairingSection.visibility = View.VISIBLE pairingSection.visibility = View.VISIBLE
pairingCodeText.text = wsService?.getPairingCode() ?: "------" pairingCodeText.text = wsService?.getPairingCode() ?: "------"
statusText.text = "Enter this code in the dashboard to pair this display" // The instruction is shown once, inside the pairing section; don't
// duplicate it in statusText.
statusText.text = ""
connectBtn.isEnabled = false connectBtn.isEnabled = false
} }
} }

View file

@ -10,6 +10,9 @@ class RemoteDisplayApp : Application() {
companion object { companion object {
const val CHANNEL_ID = "remote_display_service" const val CHANNEL_ID = "remote_display_service"
const val CHANNEL_NAME = "ScreenTinker Service" const val CHANNEL_NAME = "ScreenTinker Service"
// Separate HIGH-importance channel for the boot full-screen-intent launch.
// A full-screen intent is only honored from a high-importance channel.
const val BOOT_CHANNEL_ID = "remote_display_boot"
} }
override fun onCreate() { override fun onCreate() {
@ -19,16 +22,19 @@ class RemoteDisplayApp : Application() {
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val manager = getSystemService(NotificationManager::class.java)
CHANNEL_ID, manager.createNotificationChannel(
CHANNEL_NAME, NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).apply {
NotificationManager.IMPORTANCE_LOW
).apply {
description = "ScreenTinker background service" description = "ScreenTinker background service"
setShowBadge(false) setShowBadge(false)
} }
val manager = getSystemService(NotificationManager::class.java) )
manager.createNotificationChannel(channel) manager.createNotificationChannel(
NotificationChannel(BOOT_CHANNEL_ID, "ScreenTinker Startup", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Launches the display on boot"
setShowBadge(false)
}
)
} }
} }
} }

View file

@ -6,7 +6,7 @@ import android.content.Intent
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import com.remotedisplay.player.service.ScreenCaptureService import com.remotedisplay.player.service.MediaProjectionService
/** /**
* Transparent activity that requests MediaProjection permission. * Transparent activity that requests MediaProjection permission.
@ -50,8 +50,11 @@ class ScreenCapturePermissionActivity : Activity() {
Companion.resultData = data?.clone() as? Intent Companion.resultData = data?.clone() as? Intent
Companion.hasPermission = true Companion.hasPermission = true
// Tell the service to start the projection // #5: hand the consent to the dedicated mediaProjection foreground
ScreenCaptureService.startProjection(this, resultCode, data) // service. It must enter the foreground with the mediaProjection FGS
// type BEFORE getMediaProjection() on Android 14+ - an Activity can't
// do that, so we can't call getMediaProjection() directly here.
MediaProjectionService.start(this, resultCode, data)
getSharedPreferences("remote_display", MODE_PRIVATE) getSharedPreferences("remote_display", MODE_PRIVATE)
.edit().putBoolean("screen_capture_granted", true).apply() .edit().putBoolean("screen_capture_granted", true).apply()

View file

@ -2,11 +2,15 @@ package com.remotedisplay.player
import android.Manifest import android.Manifest
import android.accessibilityservice.AccessibilityServiceInfo import android.accessibilityservice.AccessibilityServiceInfo
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.view.View import android.view.View
@ -26,8 +30,15 @@ class SetupActivity : AppCompatActivity() {
private lateinit var notificationStatus: TextView private lateinit var notificationStatus: TextView
private lateinit var enableAccessibilityBtn: Button private lateinit var enableAccessibilityBtn: Button
private lateinit var enableInstallBtn: Button private lateinit var enableInstallBtn: Button
private lateinit var fullscreenStatus: TextView
private lateinit var enableFullscreenBtn: Button
private lateinit var batteryStatus: TextView
private lateinit var enableBatteryBtn: Button
private lateinit var overlayStatus: TextView
private lateinit var enableOverlayBtn: Button
private lateinit var continueBtn: Button private lateinit var continueBtn: Button
@SuppressLint("BatteryLife")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -40,6 +51,9 @@ class SetupActivity : AppCompatActivity() {
setContentView(R.layout.activity_setup) setContentView(R.layout.activity_setup)
// App's UI is up — clear the boot "Starting display…" notification.
getSystemService(NotificationManager::class.java)?.cancel(999)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
window.decorView.systemUiVisibility = ( window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
@ -75,11 +89,60 @@ class SetupActivity : AppCompatActivity() {
enableInstallBtn.setOnClickListener { enableInstallBtn.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = android.net.Uri.parse("package:$packageName") data = Uri.parse("package:$packageName")
}) })
} }
} }
fullscreenStatus = findViewById(R.id.fullscreenStatus)
enableFullscreenBtn = findViewById(R.id.enableFullscreenBtn)
batteryStatus = findViewById(R.id.batteryStatus)
enableBatteryBtn = findViewById(R.id.enableBatteryBtn)
overlayStatus = findViewById(R.id.overlayStatus)
enableOverlayBtn = findViewById(R.id.enableOverlayBtn)
// Display-over-other-apps: alternate boot-launch path. With this granted the
// boot receiver can directly start the activity from the background, which
// works where you can't set a launcher (e.g. Android TV).
enableOverlayBtn.setOnClickListener {
startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply {
data = Uri.parse("package:$packageName")
})
}
// Launch-on-boot needs USE_FULL_SCREEN_INTENT, which Android 14+ auto-revokes
// for non-calling apps — so the boot full-screen launcher silently fails until
// the user grants it. Older versions auto-grant it, so only show the row where
// it can actually be off.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// USE_FULL_SCREEN_INTENT is auto-granted before Android 14 — hide the row.
findViewById<View>(R.id.fullscreenRow).visibility = View.GONE
} else {
enableFullscreenBtn.setOnClickListener {
try {
startActivity(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply {
data = Uri.parse("package:$packageName")
})
} catch (e: Exception) {
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
})
}
}
}
// Battery-optimization exemption keeps the boot receiver from being deferred
// and the app from being killed in standby (esp. on OEM / TV boxes).
enableBatteryBtn.setOnClickListener {
try {
startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
})
} catch (e: Exception) {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
}
continueBtn.setOnClickListener { continueBtn.setOnClickListener {
prefs.edit().putBoolean("setup_complete", true).apply() prefs.edit().putBoolean("setup_complete", true).apply()
proceedToNext() proceedToNext()
@ -130,6 +193,27 @@ class SetupActivity : AppCompatActivity() {
if (hasNotif) View.GONE else View.VISIBLE if (hasNotif) View.GONE else View.VISIBLE
} }
// Launch on boot (full-screen intent — only restrictable on Android 14+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val canFsi = getSystemService(NotificationManager::class.java).canUseFullScreenIntent()
fullscreenStatus.text = if (canFsi) "ON" else "OFF"
fullscreenStatus.setTextColor(if (canFsi) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableFullscreenBtn.visibility = if (canFsi) View.GONE else View.VISIBLE
}
// Battery optimization exemption
val ignoringBattery = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.isIgnoringBatteryOptimizations(packageName)
batteryStatus.text = if (ignoringBattery) "ON" else "OFF"
batteryStatus.setTextColor(if (ignoringBattery) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableBatteryBtn.visibility = if (ignoringBattery) View.GONE else View.VISIBLE
// Display over other apps
val canOverlay = Settings.canDrawOverlays(this)
overlayStatus.text = if (canOverlay) "ON" else "OFF"
overlayStatus.setTextColor(if (canOverlay) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableOverlayBtn.visibility = if (canOverlay) View.GONE else View.VISIBLE
// Update continue button text // Update continue button text
val allGood = accessibilityEnabled && canInstall val allGood = accessibilityEnabled && canInstall
continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway" continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway"

View file

@ -62,4 +62,13 @@ class ServerConfig(context: Context) {
fun clear() { fun clear() {
prefs.edit().clear().apply() prefs.edit().clear().apply()
} }
// Playlist cache for offline cold-start
var cachedPlaylist: String
get() = prefs.getString("cached_playlist", "") ?: ""
set(value) = prefs.edit().putString("cached_playlist", value).apply()
fun clearPlaylistCache() {
prefs.edit().remove("cached_playlist").apply()
}
} }

View file

@ -11,6 +11,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.remotedisplay.player.util.ImageLoader
import java.io.File import java.io.File
class MediaPlayerManager( class MediaPlayerManager(
@ -18,12 +19,13 @@ class MediaPlayerManager(
private val playerView: PlayerView, private val playerView: PlayerView,
private val imageView: ImageView, private val imageView: ImageView,
private val youtubeWebView: WebView? = null, private val youtubeWebView: WebView? = null,
private val onVideoComplete: () -> Unit private val onVideoComplete: () -> Unit,
private val onImageError: (() -> Unit)? = null
) { ) {
private var exoPlayer: ExoPlayer? = null private var exoPlayer: ExoPlayer? = null
private var currentType: MediaType = MediaType.NONE private var currentType: MediaType = MediaType.NONE
enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE } enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE, WIDGET }
init { init {
setupExoPlayer() setupExoPlayer()
@ -53,13 +55,30 @@ class MediaPlayerManager(
exoPlayer?.stop() exoPlayer?.stop()
youtubeWebView?.apply { youtubeWebView?.apply {
settings.javaScriptEnabled = true com.remotedisplay.player.util.WebViewSupport.configure(this, "YouTube")
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
webViewClient = WebViewClient()
webChromeClient = WebChromeClient()
setBackgroundColor(android.graphics.Color.BLACK) setBackgroundColor(android.graphics.Color.BLACK)
loadUrl(embedUrl) // Load via an embed wrapper with a valid youtube.com origin (Error 153 fix).
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl)
if (html != null) loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
else loadUrl(embedUrl)
}
}
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
fun showWidget(url: String) {
Log.i("MediaPlayerManager", "Showing widget: $url")
currentType = MediaType.WIDGET
playerView.visibility = android.view.View.GONE
imageView.visibility = android.view.View.GONE
youtubeWebView?.visibility = android.view.View.VISIBLE
exoPlayer?.stop()
youtubeWebView?.apply {
com.remotedisplay.player.util.WebViewSupport.configure(this, "Widget")
loadUrl(url)
} }
} }
@ -89,20 +108,16 @@ class MediaPlayerManager(
exoPlayer?.stop() exoPlayer?.stop()
// Load image from URL in background
Thread { Thread {
try { val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
val connection = java.net.URL(url).openConnection()
connection.connectTimeout = 10000
connection.readTimeout = 30000
val input = connection.getInputStream()
val bitmap = android.graphics.BitmapFactory.decodeStream(input)
input.close()
if (bitmap != null) { if (bitmap != null) {
imageView.post { imageView.setImageBitmap(bitmap) } imageView.post {
try { imageView.setImageBitmap(bitmap) }
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
} }
} catch (e: Exception) { } else {
Log.e("MediaPlayerManager", "Remote image load failed: ${e.message}") Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url")
imageView.post { onImageError?.invoke() }
} }
}.start() }.start()
} }
@ -128,24 +143,23 @@ class MediaPlayerManager(
Log.i("MediaPlayerManager", "Showing image: ${file.absolutePath}") Log.i("MediaPlayerManager", "Showing image: ${file.absolutePath}")
currentType = MediaType.IMAGE currentType = MediaType.IMAGE
// Show image, hide player
playerView.visibility = android.view.View.GONE playerView.visibility = android.view.View.GONE
imageView.visibility = android.view.View.VISIBLE imageView.visibility = android.view.View.VISIBLE
youtubeWebView?.visibility = android.view.View.GONE youtubeWebView?.visibility = android.view.View.GONE
// Stop video if playing
exoPlayer?.stop() exoPlayer?.stop()
// Load image val bitmap = ImageLoader.decodeFile(file, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
try { if (bitmap == null) {
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath) Log.w("MediaPlayerManager", "Skipping unloadable image: ${file.name}")
if (bitmap != null) { onImageError?.invoke()
imageView.setImageBitmap(bitmap) return
} else {
Log.e("MediaPlayerManager", "Failed to decode image: ${file.absolutePath}")
} }
} catch (e: Exception) { try {
Log.e("MediaPlayerManager", "Error loading image: ${e.message}") imageView.setImageBitmap(bitmap)
} catch (e: Throwable) {
Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}")
onImageError?.invoke()
} }
} }

View file

@ -17,24 +17,36 @@ data class PlaylistItem(
val sortOrder: Int, val sortOrder: Int,
val enabled: Boolean = true, val enabled: Boolean = true,
val remoteUrl: String? = null, val remoteUrl: String? = null,
val muted: Boolean = false val muted: Boolean = false,
val widgetId: String? = null,
val widgetType: String? = null,
val schedules: List<ScheduleEval.Block> = emptyList()
) { ) {
val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty() val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty()
// Widget assignments have a widget_id and no downloadable content file.
val isWidget: Boolean get() = !widgetId.isNullOrEmpty()
} }
class PlaylistController( class PlaylistController(
private val onItemChanged: (PlaylistItem?) -> Unit, private val onItemChanged: (PlaylistItem?) -> Unit,
private val onPlaylistEmpty: () -> Unit, private val onPlaylistEmpty: () -> Unit,
private val onRequestRefresh: (() -> Unit)? = null private val onRequestRefresh: (() -> Unit)? = null,
private val onNothingScheduled: (() -> Unit)? = null
) { ) {
private val items = mutableListOf<PlaylistItem>() private val items = mutableListOf<PlaylistItem>()
private var currentIndex = -1 private var currentIndex = -1
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var advanceRunnable: Runnable? = null private var advanceRunnable: Runnable? = null
private var isRunning = false private var isRunning = false
// #74/#75: per-item scheduling state
@Volatile private var effectiveTimezone: String? = null
private var retryRunnable: Runnable? = null
val isPlaying: Boolean get() = isRunning && currentIndex >= 0 val isPlaying: Boolean get() = isRunning && currentIndex >= 0
/** #74/#75: device-effective IANA timezone for per-item schedule evaluation. */
fun setTimezone(tz: String?) { effectiveTimezone = tz }
val currentItem: PlaylistItem? val currentItem: PlaylistItem?
get() = if (currentIndex in items.indices) items[currentIndex] else null get() = if (currentIndex in items.indices) items[currentIndex] else null
@ -51,7 +63,8 @@ class PlaylistController(
newItems.add( newItems.add(
PlaylistItem( PlaylistItem(
assignmentId = obj.optInt("id", 0), assignmentId = obj.optInt("id", 0),
contentId = obj.getString("content_id"), // Tolerant: widget assignments have no content_id (getString threw).
contentId = if (obj.isNull("content_id")) "" else obj.optString("content_id", ""),
filename = obj.optString("filename", "unknown"), filename = obj.optString("filename", "unknown"),
mimeType = obj.optString("mime_type", "video/mp4"), mimeType = obj.optString("mime_type", "video/mp4"),
filepath = obj.optString("filepath", ""), filepath = obj.optString("filepath", ""),
@ -60,14 +73,24 @@ class PlaylistController(
sortOrder = obj.optInt("sort_order", 0), sortOrder = obj.optInt("sort_order", 0),
enabled = obj.optInt("enabled", 1) == 1, enabled = obj.optInt("enabled", 1) == 1,
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null }, remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
muted = obj.optInt("muted", 0) == 1 muted = obj.optInt("muted", 0) == 1,
widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null },
widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null },
schedules = parseSchedules(obj.optJSONArray("schedules"))
) )
) )
} }
// Check if playlist actually changed // Check if playlist actually changed (key on content OR widget id, since
val oldContentIds = items.map { it.contentId } // widget items share an empty contentId).
val newContentIds = newItems.map { it.contentId } // #74/#75: a schedule edit changes playback even when content is identical, so
// the change signature must include schedules (else updated blocks are dropped).
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" +
it.schedules.joinToString(";") { b ->
b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "")
}
val oldContentIds = items.map(::sig)
val newContentIds = newItems.map(::sig)
val playlistChanged = oldContentIds != newContentIds val playlistChanged = oldContentIds != newContentIds
if (!playlistChanged && items.isNotEmpty()) { if (!playlistChanged && items.isNotEmpty()) {
@ -98,9 +121,10 @@ class PlaylistController(
return return
} }
} }
// Current item was removed or nothing was playing - start from beginning // Current item was removed or nothing was playing - start from the first
currentIndex = 0 // schedule-active item; idle if none are active right now.
playCurrentItem() val idx = firstActiveIndex()
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled()
} else { } else {
currentIndex = 0 currentIndex = 0
} }
@ -122,12 +146,12 @@ class PlaylistController(
fun start() { fun start() {
isRunning = true isRunning = true
if (items.isNotEmpty()) { if (items.isEmpty()) { onPlaylistEmpty(); return }
if (currentIndex < 0) currentIndex = 0 // #74/#75: begin on the first schedule-active item; idle if none.
val idx = firstActiveIndex()
if (idx < 0) { showNothingScheduled(); return }
currentIndex = idx
playCurrentItem() playCurrentItem()
} else {
onPlaylistEmpty()
}
} }
fun startIfNeeded() { fun startIfNeeded() {
@ -148,13 +172,17 @@ class PlaylistController(
fun stop() { fun stop() {
isRunning = false isRunning = false
cancelAdvance() cancelAdvance()
cancelRetry()
} }
fun next() { fun next() {
if (items.isEmpty()) return if (items.isEmpty()) return
currentIndex = (currentIndex + 1) % items.size
// Request a playlist refresh between plays so new content gets picked up // Request a playlist refresh between plays so new content gets picked up
onRequestRefresh?.invoke() onRequestRefresh?.invoke()
// #74/#75: advance to the next item the schedule allows now; idle if none.
val idx = nextActiveIndex(currentIndex)
if (idx < 0) { showNothingScheduled(); return }
currentIndex = idx
playCurrentItem() playCurrentItem()
} }
@ -165,12 +193,14 @@ class PlaylistController(
private fun playCurrentItem() { private fun playCurrentItem() {
cancelAdvance() cancelAdvance()
cancelRetry()
val item = currentItem ?: return val item = currentItem ?: return
Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)") Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)")
onItemChanged(item) onItemChanged(item)
// For images, auto-advance after duration. For videos, wait for completion callback. // For images and widgets, auto-advance after duration. For videos, wait
if (item.mimeType.startsWith("image/")) { // for the completion callback.
if (item.mimeType.startsWith("image/") || item.isWidget) {
scheduleAdvance(item.durationSec * 1000L) scheduleAdvance(item.durationSec * 1000L)
} }
} }
@ -185,4 +215,64 @@ class PlaylistController(
advanceRunnable?.let { handler.removeCallbacks(it) } advanceRunnable?.let { handler.removeCallbacks(it) }
advanceRunnable = null advanceRunnable = null
} }
private fun cancelRetry() {
retryRunnable?.let { handler.removeCallbacks(it) }
retryRunnable = null
}
// #74/#75 schedule helpers ---------------------------------------------------
private fun scheduleAllows(item: PlaylistItem): Boolean =
item.schedules.isEmpty() ||
ScheduleEval.isItemActiveNow(item.schedules, System.currentTimeMillis(), effectiveTimezone)
private fun firstActiveIndex(): Int {
for (i in items.indices) if (scheduleAllows(items[i])) return i
return -1
}
private fun nextActiveIndex(from: Int): Int {
if (items.isEmpty()) return -1
for (i in 1..items.size) {
val idx = (from + i) % items.size
if (scheduleAllows(items[idx])) return idx
}
return -1
}
// Every item filtered out: show the idle screen and re-check shortly, since a
// daypart may open. (Boundary re-evaluation otherwise happens on advance.)
private fun showNothingScheduled() {
cancelAdvance()
(onNothingScheduled ?: onPlaylistEmpty)()
cancelRetry()
retryRunnable = Runnable {
if (isRunning && items.isNotEmpty()) {
val idx = firstActiveIndex()
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled()
}
}
handler.postDelayed(retryRunnable!!, 30_000L)
}
private fun parseSchedules(arr: JSONArray?): List<ScheduleEval.Block> {
if (arr == null) return emptyList()
val out = ArrayList<ScheduleEval.Block>(arr.length())
for (j in 0 until arr.length()) {
val s = arr.getJSONObject(j)
val d = s.getJSONArray("days")
val days = HashSet<Int>(d.length())
for (k in 0 until d.length()) days.add(d.getInt(k))
out.add(
ScheduleEval.Block(
days = days,
start = s.getString("start"),
end = s.getString("end"),
startDate = if (s.isNull("start_date")) null else s.optString("start_date").ifEmpty { null },
endDate = if (s.isNull("end_date")) null else s.optString("end_date").ifEmpty { null }
)
)
}
return out
}
} }

View file

@ -0,0 +1,80 @@
package com.remotedisplay.player.player
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* Canonical per-playlist-item schedule evaluator (#74 dayparting + #75 expiry) -
* Kotlin port of server/lib/schedule-eval.js.
*
* CONTRACT: shared/schedule-vectors.json. This must agree with the JS evaluator
* (server/web/Tizen) on every vector. If it disagrees with a vector, this is wrong.
*
* Time model: instants are UTC; blocks are LOCAL wall-clock rules interpreted in
* the device's effective IANA timezone (DST handled by java.time). Blocks are never
* converted to UTC.
*
* Block semantics:
* - within a block, day AND date AND time must all pass; blocks OR together
* - zero blocks = always on ("no schedule = always plays")
* - time window is [start, end): start inclusive, end exclusive ("24:00" = end of day)
* - start > end crosses midnight; the day/date test anchors to the day the window STARTED
*
* FAILS OPEN: any error (bad timezone, malformed block) returns true so the item
* PLAYS. A blank screen is worse than an over-running promo.
*/
object ScheduleEval {
data class Block(
val days: Set<Int>, // 0=Sun .. 6=Sat
val start: String, // "HH:MM"
val end: String, // "HH:MM" or "24:00"
val startDate: String?, // "YYYY-MM-DD" or null = no lower bound
val endDate: String? // "YYYY-MM-DD" or null = no upper bound
)
fun isItemActiveNow(blocks: List<Block>?, utcNowMs: Long, ianaTz: String?): Boolean {
if (blocks.isNullOrEmpty()) return true
return try {
val zone = if (ianaTz.isNullOrBlank()) ZoneId.systemDefault() else ZoneId.of(ianaTz)
val zdt = Instant.ofEpochMilli(utcNowMs).atZone(zone)
val dow = zdt.dayOfWeek.value % 7 // java Mon=1..Sun=7 -> Sun=0..Sat=6
val nowMin = zdt.hour * 60 + zdt.minute
val date = zdt.toLocalDate()
blocks.any { blockMatches(it, dow, nowMin, date) }
} catch (e: Exception) {
true // fail open
}
}
private fun hm(s: String): Int { val p = s.split(":"); return p[0].toInt() * 60 + p[1].toInt() } // "24:00" -> 1440
private fun dayOk(dow: Int, days: Set<Int>): Boolean = days.contains(dow)
private fun dateOk(date: LocalDate, startDate: String?, endDate: String?): Boolean {
if (startDate != null && date.isBefore(LocalDate.parse(startDate))) return false
if (endDate != null && date.isAfter(LocalDate.parse(endDate))) return false // inclusive
return true
}
private fun blockMatches(b: Block, dow: Int, nowMin: Int, date: LocalDate): Boolean {
val s = hm(b.start); val e = hm(b.end)
if (s <= e) {
// same-day window [s, e), anchored to today
if (nowMin < s || nowMin >= e) return false
return dayOk(dow, b.days) && dateOk(date, b.startDate, b.endDate)
}
// overnight wrap
if (nowMin >= s) {
// before-midnight portion: anchor = today
return dayOk(dow, b.days) && dateOk(date, b.startDate, b.endDate)
}
if (nowMin < e) {
// after-midnight portion: anchor = the day it started = yesterday
val y = date.minusDays(1)
return dayOk((dow + 6) % 7, b.days) && dateOk(y, b.startDate, b.endDate)
}
return false
}
}

View file

@ -2,6 +2,8 @@ package com.remotedisplay.player.player
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -35,16 +37,24 @@ class ZoneManager(
private val onAllVideosComplete: () -> Unit private val onAllVideosComplete: () -> Unit
) { ) {
private val TAG = "ZoneManager" private val TAG = "ZoneManager"
private val handler = Handler(Looper.getMainLooper())
private val zoneViews = mutableMapOf<String, View>() private val zoneViews = mutableMapOf<String, View>()
private val zoneExoPlayers = mutableMapOf<String, ExoPlayer>() private val zoneExoPlayers = mutableMapOf<String, ExoPlayer>()
// Per-zone rotation timers: each zone cycles its own list of assignments.
private val zoneRotators = mutableMapOf<String, Runnable>()
private var zones = listOf<Zone>() private var zones = listOf<Zone>()
private var activeVideoCount = 0 // Render context kept for rotation re-renders.
private var completedVideoCount = 0 private var renderServerUrl = ""
private var renderCache: com.remotedisplay.player.data.ContentCache? = null
var currentLayoutId: String? = null var currentLayoutId: String? = null
private set private set
var lastAssignmentSig: String? = null var lastAssignmentSig: String? = null
// #74/#75: device-effective IANA timezone for per-item schedule evaluation.
@Volatile private var effectiveTimezone: String? = null
fun setTimezone(tz: String?) { effectiveTimezone = tz }
fun hasZones(): Boolean = zones.isNotEmpty() fun hasZones(): Boolean = zones.isNotEmpty()
fun setupZones(zonesJson: JSONArray, layoutId: String? = null) { fun setupZones(zonesJson: JSONArray, layoutId: String? = null) {
@ -68,86 +78,140 @@ class ZoneManager(
} }
fun renderAssignments(assignments: JSONArray, serverUrl: String, contentCache: com.remotedisplay.player.data.ContentCache) { fun renderAssignments(assignments: JSONArray, serverUrl: String, contentCache: com.remotedisplay.player.data.ContentCache) {
// Clear existing zone views // Clear ONLY our own zone views/timers. `container` is the activity root and
container.removeAllViews() // also holds the static playerView/imageView/youtubeWebView/statusOverlay -
// removeAllViews() here would detach those and black the screen on switch-back.
cancelAllRotations()
zoneViews.values.forEach { container.removeView(it) }
zoneViews.clear() zoneViews.clear()
releaseExoPlayers() releaseExoPlayers()
activeVideoCount = 0 renderServerUrl = serverUrl
completedVideoCount = 0 renderCache = contentCache
val containerWidth = container.width val containerWidth = container.width
val containerHeight = container.height val containerHeight = container.height
if (containerWidth == 0 || containerHeight == 0) { if (containerWidth == 0 || containerHeight == 0) {
// Container not laid out yet, post delayed // Container not laid out yet, retry after layout.
container.post { renderAssignments(assignments, serverUrl, contentCache) } container.post { renderAssignments(assignments, serverUrl, contentCache) }
return return
} }
// Map assignments by zone_id // Group assignments by zone_id, ordered by sort_order so rotation is stable.
val assignmentsByZone = mutableMapOf<String?, MutableList<JSONObject>>() val assignmentsByZone = mutableMapOf<String?, MutableList<JSONObject>>()
for (i in 0 until assignments.length()) { for (i in 0 until assignments.length()) {
val a = assignments.getJSONObject(i) val a = assignments.getJSONObject(i)
val zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null) val zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null)
assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a) assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a)
} }
assignmentsByZone.values.forEach { list -> list.sortBy { it.optInt("sort_order", 0) } }
// Render each zone - only show content specifically assigned to this zone // Unassigned content (zone_id=null) goes to the FIRST zone only.
// Unassigned content (zone_id=null) goes to the FIRST zone only
var unassignedUsed = false var unassignedUsed = false
for (zone in zones.sortedBy { it.zIndex }) { for (zone in zones.sortedBy { it.zIndex }) {
val zoneAssignments: List<JSONObject> = assignmentsByZone[zone.id] val zoneAssignments: List<JSONObject> = assignmentsByZone[zone.id]
?: if (!unassignedUsed) { unassignedUsed = true; assignmentsByZone[null] ?: emptyList() } else emptyList() ?: if (!unassignedUsed) { unassignedUsed = true; assignmentsByZone[null] ?: emptyList() } else emptyList()
val firstAssignment = zoneAssignments.firstOrNull() ?: continue if (zoneAssignments.isEmpty()) continue
// Calculate pixel position
val x = (zone.xPercent / 100f * containerWidth).toInt() val x = (zone.xPercent / 100f * containerWidth).toInt()
val y = (zone.yPercent / 100f * containerHeight).toInt() val y = (zone.yPercent / 100f * containerHeight).toInt()
val w = (zone.widthPercent / 100f * containerWidth).toInt() val w = (zone.widthPercent / 100f * containerWidth).toInt()
val h = (zone.heightPercent / 100f * containerHeight).toInt() val h = (zone.heightPercent / 100f * containerHeight).toInt()
val params = FrameLayout.LayoutParams(w, h).apply { leftMargin = x; topMargin = y }
val params = FrameLayout.LayoutParams(w, h).apply { com.remotedisplay.player.util.DebugLog.i("Zone", "Zone '${zone.name}' (${zone.widthPercent.toInt()}x${zone.heightPercent.toInt()}%) -> ${zoneAssignments.size} item(s)")
leftMargin = x showZoneItem(zone, zoneAssignments, 0, params)
topMargin = y }
Log.i(TAG, "Rendered ${zoneViews.size} zone views")
} }
val mimeType = firstAssignment.optString("mime_type", "") // #74/#75 zone schedule helpers.
val remoteUrl = if (firstAssignment.isNull("remote_url")) null else firstAssignment.optString("remote_url", null) private fun assignmentAllows(a: JSONObject): Boolean {
val widgetType = if (firstAssignment.isNull("widget_type")) null else firstAssignment.optString("widget_type", null) val arr = a.optJSONArray("schedules") ?: return true
val widgetConfig = if (firstAssignment.isNull("widget_config")) null else firstAssignment.optString("widget_config", null) if (arr.length() == 0) return true
val contentId = if (firstAssignment.isNull("content_id")) null else firstAssignment.optString("content_id", null) val blocks = ArrayList<ScheduleEval.Block>(arr.length())
val filepath = firstAssignment.optString("filepath", "") for (j in 0 until arr.length()) {
val isMuted = firstAssignment.optInt("muted", 0) == 1 val s = arr.getJSONObject(j)
val d = s.getJSONArray("days")
val days = HashSet<Int>(d.length())
for (k in 0 until d.length()) days.add(d.getInt(k))
blocks.add(
ScheduleEval.Block(
days, s.getString("start"), s.getString("end"),
if (s.isNull("start_date")) null else s.optString("start_date").ifEmpty { null },
if (s.isNull("end_date")) null else s.optString("end_date").ifEmpty { null }
)
)
}
return ScheduleEval.isItemActiveNow(blocks, System.currentTimeMillis(), effectiveTimezone)
}
private fun zoneNextActive(assignments: List<JSONObject>, from: Int): Int {
for (i in assignments.indices) {
val idx = (from + i) % assignments.size
if (assignmentAllows(assignments[idx])) return idx
}
return -1
}
// Render assignment[index] in a zone, replacing its current view. If the zone
// has more than one assignment it rotates: images/widgets advance on a duration
// timer; videos advance when they end (single-item zones loop the video).
private fun showZoneItem(zone: Zone, assignments: List<JSONObject>, index: Int, params: FrameLayout.LayoutParams) {
cancelZoneRotation(zone.id)
zoneViews.remove(zone.id)?.let { container.removeView(it) }
zoneExoPlayers.remove(zone.id)?.release()
// #74/#75: skip items whose schedule excludes them now; blank-idle the zone
// and re-check shortly (a daypart may open) if none are active.
val activeIdx = zoneNextActive(assignments, index)
if (activeIdx < 0) {
scheduleZoneAdvance(zone.id, 30_000L) { showZoneItem(zone, assignments, 0, params) }
return
}
val a = assignments[activeIdx]
// Scheduled zones cycle even with one active item so windows re-evaluate.
val multi = assignments.size > 1 || assignments.any { (it.optJSONArray("schedules")?.length() ?: 0) > 0 }
val advance: () -> Unit = { showZoneItem(zone, assignments, activeIdx + 1, params) }
val mimeType = a.optString("mime_type", "")
val remoteUrl = if (a.isNull("remote_url")) null else a.optString("remote_url", null)
val widgetType = if (a.isNull("widget_type")) null else a.optString("widget_type", null)
val contentId = if (a.isNull("content_id")) null else a.optString("content_id", null)
val filepath = a.optString("filepath", "")
val isMuted = a.optInt("muted", 0) == 1
val durationMs = a.optInt("duration_sec", 10).coerceAtLeast(3) * 1000L
// Per-zone content switch log (fires on initial render AND each rotation), so
// the live debug panel shows each zone advancing on its own interval.
val label = a.optString("filename", "").ifEmpty { widgetType?.let { "widget:$it" } ?: mimeType.ifEmpty { "item" } }
com.remotedisplay.player.util.DebugLog.i("Zone", "'${zone.name}' [${activeIdx + 1}/${assignments.size}] -> $label (${durationMs / 1000}s)")
when { when {
// Widget - render in WebView // Widget - render in WebView
widgetType != null -> { widgetType != null -> {
val widgetId = firstAssignment.optString("widget_id", "") val widgetId = a.optString("widget_id", "")
val webView = createWebView() val webView = createWebView()
webView.loadUrl("$serverUrl/api/widgets/$widgetId/render") webView.loadUrl("$renderServerUrl/api/widgets/$widgetId/render")
webView.layoutParams = params webView.layoutParams = params
container.addView(webView) container.addView(webView); zoneViews[zone.id] = webView
zoneViews[zone.id] = webView if (multi) scheduleZoneAdvance(zone.id, durationMs, advance)
Log.i(TAG, "Zone ${zone.name}: widget $widgetType")
} }
// YouTube - render via an embed wrapper with a valid origin (Error 153 fix)
// YouTube - render in WebView
mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> { mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> {
val webView = createWebView() val webView = createWebView()
webView.loadUrl(remoteUrl) val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl)
if (html != null) webView.loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
else webView.loadUrl(remoteUrl)
webView.layoutParams = params webView.layoutParams = params
container.addView(webView) container.addView(webView); zoneViews[zone.id] = webView
zoneViews[zone.id] = webView if (multi) scheduleZoneAdvance(zone.id, durationMs, advance)
Log.i(TAG, "Zone ${zone.name}: youtube $remoteUrl")
} }
// Video // Video
mimeType.startsWith("video/") -> { mimeType.startsWith("video/") -> {
val src = if (!remoteUrl.isNullOrEmpty()) remoteUrl val src = if (!remoteUrl.isNullOrEmpty()) remoteUrl
else if (contentId != null) contentCache.getCachedFile(contentId)?.let { Uri.fromFile(it).toString() } else if (contentId != null) renderCache?.getCachedFile(contentId)?.let { Uri.fromFile(it).toString() }
?: "$serverUrl/uploads/content/$filepath" ?: "$renderServerUrl/uploads/content/$filepath"
else continue else { if (multi) scheduleZoneAdvance(zone.id, durationMs, advance); return }
val playerView = (android.view.LayoutInflater.from(context) val playerView = (android.view.LayoutInflater.from(context)
.inflate(com.remotedisplay.player.R.layout.zone_player, null) as PlayerView).apply { .inflate(com.remotedisplay.player.R.layout.zone_player, null) as PlayerView).apply {
useController = false useController = false
@ -155,20 +219,19 @@ class ZoneManager(
} }
val exoPlayer = ExoPlayer.Builder(context).build().apply { val exoPlayer = ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(src)) setMediaItem(MediaItem.fromUri(src))
repeatMode = Player.REPEAT_MODE_ALL repeatMode = if (multi) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ALL
// Use muted flag from assignment, default unmuted for first video
volume = if (isMuted) 0f else 1f volume = if (isMuted) 0f else 1f
if (multi) addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) handler.post { advance() }
}
})
prepare() prepare()
playWhenReady = true playWhenReady = true
} }
playerView.player = exoPlayer playerView.player = exoPlayer
container.addView(playerView) container.addView(playerView); zoneViews[zone.id] = playerView; zoneExoPlayers[zone.id] = exoPlayer
zoneViews[zone.id] = playerView
zoneExoPlayers[zone.id] = exoPlayer
activeVideoCount++
Log.i(TAG, "Zone ${zone.name}: video $src")
} }
// Image // Image
mimeType.startsWith("image/") -> { mimeType.startsWith("image/") -> {
val imageView = ImageView(context).apply { val imageView = ImageView(context).apply {
@ -179,44 +242,63 @@ class ZoneManager(
} }
layoutParams = params layoutParams = params
} }
val targetW = if (params.width > 0) params.width else com.remotedisplay.player.util.ImageLoader.screenWidth(context)
// Load image val targetH = if (params.height > 0) params.height else com.remotedisplay.player.util.ImageLoader.screenHeight(context)
val file = contentId?.let { contentCache.getCachedFile(it) } val file = contentId?.let { renderCache?.getCachedFile(it) }
if (file != null) { if (file != null) {
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath) val bitmap = com.remotedisplay.player.util.ImageLoader.decodeFile(file, targetW, targetH)
if (bitmap != null) imageView.setImageBitmap(bitmap) if (bitmap != null) {
} else if (!remoteUrl.isNullOrEmpty()) { try { imageView.setImageBitmap(bitmap) } catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") }
// Load from URL in background } else {
Log.w(TAG, "Zone ${zone.name}: skipping unloadable image $contentId")
}
} else {
// #78: not in the local cache yet (first-sync download still in flight, or a
// zone whose content the preloader hasn't fetched). Load straight from the
// server - mirrors how the video branch above falls back to a server URL -
// so the zone isn't blank until a restart populates the cache.
val imgUrl = if (!remoteUrl.isNullOrEmpty()) remoteUrl
else if (contentId != null) "$renderServerUrl/api/content/$contentId/file"
else null
if (imgUrl != null) {
Thread { Thread {
try { val bitmap = com.remotedisplay.player.util.ImageLoader.decodeUrl(imgUrl, targetW, targetH)
val connection = java.net.URL(remoteUrl).openConnection() if (bitmap != null) {
val input = connection.getInputStream() imageView.post {
val bitmap = android.graphics.BitmapFactory.decodeStream(input) try { imageView.setImageBitmap(bitmap) } catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") }
input.close() }
imageView.post { if (bitmap != null) imageView.setImageBitmap(bitmap) } } else {
} catch (e: Exception) { Log.w(TAG, "Zone ${zone.name}: unloadable image $contentId via $imgUrl")
Log.e(TAG, "Image load failed: ${e.message}")
} }
}.start() }.start()
} }
container.addView(imageView)
zoneViews[zone.id] = imageView
Log.i(TAG, "Zone ${zone.name}: image")
} }
container.addView(imageView); zoneViews[zone.id] = imageView
if (multi) scheduleZoneAdvance(zone.id, durationMs, advance)
}
// Unknown / empty assignment - keep rotating so it doesn't get stuck.
else -> { if (multi) scheduleZoneAdvance(zone.id, durationMs, advance) }
} }
} }
Log.i(TAG, "Rendered ${zoneViews.size} zone views") private fun scheduleZoneAdvance(zoneId: String, delayMs: Long, advance: () -> Unit) {
val r = Runnable { advance() }
zoneRotators[zoneId] = r
handler.postDelayed(r, delayMs)
}
private fun cancelZoneRotation(zoneId: String) {
zoneRotators.remove(zoneId)?.let { handler.removeCallbacks(it) }
}
private fun cancelAllRotations() {
zoneRotators.values.forEach { handler.removeCallbacks(it) }
zoneRotators.clear()
} }
private fun createWebView(): WebView { private fun createWebView(): WebView {
return WebView(context).apply { return WebView(context).apply {
settings.javaScriptEnabled = true com.remotedisplay.player.util.WebViewSupport.configure(this, "Zone")
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
setBackgroundColor(android.graphics.Color.TRANSPARENT)
webViewClient = WebViewClient()
} }
} }
@ -226,8 +308,12 @@ class ZoneManager(
} }
fun cleanup() { fun cleanup() {
cancelAllRotations()
releaseExoPlayers() releaseExoPlayers()
container.removeAllViews() // Remove ONLY the views we added for zones; the activity's static views live
// in this same container and must NOT be removed (else single-zone/fullscreen
// playback, which reuses them, renders black).
zoneViews.values.forEach { container.removeView(it) }
zoneViews.clear() zoneViews.clear()
zones = listOf() zones = listOf()
} }

View file

@ -1,15 +1,9 @@
package com.remotedisplay.player.service package com.remotedisplay.player.service
import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat
import android.app.NotificationManager
import com.remotedisplay.player.MainActivity
import com.remotedisplay.player.RemoteDisplayApp
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@ -19,57 +13,9 @@ class BootReceiver : BroadcastReceiver() {
action == "com.htc.intent.action.QUICKBOOT_POWERON") { action == "com.htc.intent.action.QUICKBOOT_POWERON") {
Log.i("BootReceiver", "Boot completed (action=$action), launching ScreenTinker") Log.i("BootReceiver", "Boot completed (action=$action), launching ScreenTinker")
// #96: boot + post-update relaunch share one cascade (overlay-direct -> FSI/
// Start the foreground service // tap-to-resume). See Relauncher.
try { Relauncher.relaunch(context, Relauncher.BOOT)
val serviceIntent = Intent(context, WebSocketService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(serviceIntent)
} else {
context.startService(serviceIntent)
}
Log.i("BootReceiver", "WebSocket service started")
} catch (e: Exception) {
Log.e("BootReceiver", "Failed to start service: ${e.message}")
}
// Use a full-screen intent to launch the activity (bypasses Android 12+ restrictions)
try {
val launchIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val pendingIntent = PendingIntent.getActivity(
context, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, RemoteDisplayApp.CHANNEL_ID)
.setContentTitle("ScreenTinker")
.setContentText("Starting display...")
.setSmallIcon(android.R.drawable.ic_media_play)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setFullScreenIntent(pendingIntent, true)
.setAutoCancel(true)
.build()
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(999, notification)
Log.i("BootReceiver", "Full-screen intent notification sent")
} catch (e: Exception) {
Log.e("BootReceiver", "Failed to launch via notification: ${e.message}")
// Fallback: try direct launch
try {
val launchIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
context.startActivity(launchIntent)
} catch (e2: Exception) {
Log.e("BootReceiver", "Direct launch also failed: ${e2.message}")
}
}
} }
} }
} }

View file

@ -0,0 +1,101 @@
package com.remotedisplay.player.service
import android.app.Activity
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.remotedisplay.player.RemoteDisplayApp
/**
* #5: Foreground service that owns the MediaProjection FGS type for system-wide
* screen capture (the `enable_system_capture` command).
*
* Android 14+ requires an FGS of type `mediaProjection` to be running - started
* AFTER the user grants consent - before MediaProjectionManager.getMediaProjection()
* may be called. An Activity can't enter that foreground state, so the consent
* Activity hands the result here. Kept separate from WebSocketService so the
* always-on service never claims the mediaProjection type at boot.
*/
class MediaProjectionService : Service() {
companion object {
private const val TAG = "MediaProjectionSvc"
private const val NOTIF_ID = 2
private const val EXTRA_RESULT_CODE = "result_code"
private const val EXTRA_RESULT_DATA = "result_data"
/** Start the projection FGS with the user's consent result. */
fun start(context: Context, resultCode: Int, data: Intent) {
val intent = Intent(context, MediaProjectionService::class.java).apply {
putExtra(EXTRA_RESULT_CODE, resultCode)
putExtra(EXTRA_RESULT_DATA, data)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, MediaProjectionService::class.java))
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Enter the foreground with the mediaProjection type FIRST (required on
// Android 14+ before getMediaProjection()).
startForegroundCompat()
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED)
?: Activity.RESULT_CANCELED
@Suppress("DEPRECATION")
val data: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else {
intent?.getParcelableExtra(EXTRA_RESULT_DATA)
}
if (resultCode != Activity.RESULT_OK || data == null) {
Log.e(TAG, "Missing/invalid projection consent; stopping service")
stopSelf()
return START_NOT_STICKY
}
return try {
ScreenCaptureService.startProjection(this, resultCode, data)
START_STICKY
} catch (e: Throwable) {
Log.e(TAG, "startProjection failed: ${e.message}", e)
stopSelf()
START_NOT_STICKY
}
}
private fun startForegroundCompat() {
val notif = NotificationCompat.Builder(this, RemoteDisplayApp.CHANNEL_ID)
.setContentTitle("ScreenTinker")
.setContentText("Screen capture active")
.setSmallIcon(android.R.drawable.ic_menu_camera)
.setOngoing(true)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
} else {
startForeground(NOTIF_ID, notif)
}
}
override fun onDestroy() {
// Release the projection when the service goes away.
try { ScreenCaptureService.stop() } catch (_: Throwable) {}
super.onDestroy()
}
}

View file

@ -0,0 +1,23 @@
package com.remotedisplay.player.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* #96: fires after the player updates itself via the OTA. When the app installs a new APK of
* its own package, the system sends ACTION_MY_PACKAGE_REPLACED to the freshly-installed app
* (in a new process). Without this, PACKAGE_REPLACED kills the old process and nothing brings
* MainActivity back - the screen drops to the launcher, which is the 1.9.0 fleet bug.
*
* Relaunch through the exact same cascade as boot (see [Relauncher]).
*/
class PackageReplacedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
Log.i("PackageReplaced", "App updated (MY_PACKAGE_REPLACED) - relaunching")
Relauncher.relaunch(context, Relauncher.UPDATE)
}
}
}

View file

@ -8,6 +8,7 @@ import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import android.view.WindowManager import android.view.WindowManager
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
class PowerAccessibilityService : AccessibilityService() { class PowerAccessibilityService : AccessibilityService() {
@ -22,7 +23,53 @@ class PowerAccessibilityService : AccessibilityService() {
Log.i(TAG, "Service connected") Log.i(TAG, "Service connected")
} }
override fun onAccessibilityEvent(event: AccessibilityEvent?) {} private var lastConfirm = 0L
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
val pkg = event?.packageName?.toString() ?: return
// Auto-confirm the system app-update dialog so OTA updates apply unattended
// on kiosk screens (no one is there to tap "Update"). Scoped to the package
// installer only, so this never touches anything else.
if (!pkg.contains("packageinstaller", ignoreCase = true)) return
if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED &&
event.eventType != AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) return
autoConfirmInstall()
}
private fun autoConfirmInstall() {
val now = System.currentTimeMillis()
if (now - lastConfirm < 1500) return // debounce repeated content events
val root = rootInActiveWindow ?: return
// Positive button by resource id first (locale-independent), then by label.
val ids = listOf(
"com.google.android.packageinstaller:id/ok_button",
"com.android.packageinstaller:id/ok_button",
"android:id/button1"
)
for (id in ids) {
for (n in root.findAccessibilityNodeInfosByViewId(id)) {
if (clickButton(n)) { lastConfirm = now; Log.i(TAG, "Auto-confirmed install via $id"); return }
}
}
for (label in listOf("Update", "Install", "Reinstall", "Continue")) {
for (n in root.findAccessibilityNodeInfosByText(label)) {
if (clickButton(n)) { lastConfirm = now; Log.i(TAG, "Auto-confirmed install via '$label'"); return }
}
}
}
// Click the node or its nearest clickable+enabled ancestor (the button).
private fun clickButton(node: AccessibilityNodeInfo?): Boolean {
var cur = node
var depth = 0
while (cur != null && depth < 4) {
if (cur.isClickable && cur.isEnabled) return cur.performAction(AccessibilityNodeInfo.ACTION_CLICK)
cur = cur.parent
depth++
}
return false
}
override fun onInterrupt() {} override fun onInterrupt() {}
// Global actions // Global actions

View file

@ -0,0 +1,103 @@
package com.remotedisplay.player.service
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import com.remotedisplay.player.MainActivity
import com.remotedisplay.player.RemoteDisplayApp
/**
* Brings the player back to the foreground after a trigger (device boot or a self-update).
* Shared by [BootReceiver] and [PackageReplacedReceiver] so both relaunch through the SAME
* cascade (#96).
*
* A BroadcastReceiver runs in the background, and Android 10+ blocks a bare startActivity
* from the background. The cascade, most-reliable first:
*
* 1. Overlay-direct startActivity legal on EVERY version IF SYSTEM_ALERT_WINDOW is
* granted (the documented background-activity-launch exemption). Covers MAXHUB
* (elevated), any properly-onboarded device, and Fire OS 7 (Android 9, no restriction).
* 2. Notification on Android <14 a full-screen intent AUTO-LAUNCHES the activity (covers
* FireOS, which is Android 911); on 14+, where USE_FULL_SCREEN_INTENT is auto-revoked,
* it degrades to a VISIBLE, tappable "tap to resume" prompt. That is the requirement
* (a) fail-loud path: human-recoverable, never a silent dark screen. The server sees
* the device's next check-in or its absence via the #96 update logging.
*
* The only device class with no path here is vanilla Android 14+ with neither the overlay
* granted nor the app set as home launcher for those it stops at the visible prompt.
*/
object Relauncher {
private const val TAG = "Relauncher"
const val UPDATE = "update"
const val BOOT = "boot"
fun relaunch(context: Context, reason: String) {
// Keep the WS foreground service alive (it drives playback + reconnect).
try {
val svc = Intent(context, WebSocketService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(svc)
else context.startService(svc)
Log.i(TAG, "[$reason] WebSocket service started")
} catch (e: Exception) {
Log.e(TAG, "[$reason] Failed to start service: ${e.message}")
}
val launchIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
// 1. Overlay-direct: the most reliable bg-launch path when the overlay is granted.
var launched = false
if (Settings.canDrawOverlays(context)) {
try {
context.startActivity(launchIntent)
launched = true
Log.i(TAG, "[$reason] Direct launch (overlay permission)")
} catch (e: Exception) {
Log.e(TAG, "[$reason] Direct launch failed: ${e.message}")
}
}
// 2. Notification: <14 full-screen-intent auto-launch; 14+/no-overlay the visible
// tap-to-resume prompt. Posted even if (1) launched, so a 14+ device that could
// not auto-launch always has a tappable way back (fail loud, never dark).
postRelaunchNotification(context, launchIntent, reason, launched)
}
private fun postRelaunchNotification(context: Context, launchIntent: Intent, reason: String, alreadyLaunched: Boolean) {
try {
val pi = PendingIntent.getActivity(
context, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val isUpdate = reason == UPDATE
val builder = NotificationCompat.Builder(context, RemoteDisplayApp.BOOT_CHANNEL_ID)
.setContentTitle(if (isUpdate) "ScreenTinker updated" else "ScreenTinker")
.setContentText(if (isUpdate) "Tap to resume the display" else "Starting display...")
.setSmallIcon(android.R.drawable.ic_media_play)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setContentIntent(pi) // tap -> launch (the path on 14+ where FSI is revoked)
.setFullScreenIntent(pi, true) // <14: auto-launch
.setAutoCancel(true)
// Fail-loud: if we could not auto-launch (14+, no overlay), keep the prompt
// sticky until the operator taps it to resume.
if (isUpdate && !alreadyLaunched) builder.setOngoing(true)
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(999, builder.build())
Log.i(TAG, "[$reason] Relaunch notification posted (fullScreenIntent + tappable, ongoing=${isUpdate && !alreadyLaunched})")
} catch (e: Exception) {
Log.e(TAG, "[$reason] Notification failed: ${e.message}")
if (!alreadyLaunched) {
// last-ditch: try a direct launch even though bg-launch may be blocked.
try { context.startActivity(launchIntent) } catch (e2: Exception) { Log.e(TAG, "[$reason] Last-ditch launch failed: ${e2.message}") }
}
}
}
}

View file

@ -54,13 +54,9 @@ object ScreenCaptureService {
imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4) imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4)
virtualDisplay = projection.createVirtualDisplay( // #5: Android 14+ requires a Callback registered BEFORE createVirtualDisplay,
"ScreenTinker", // otherwise createVirtualDisplay throws IllegalStateException. (Was registered
captureWidth, captureHeight, density, // after, which broke system capture on Android 14+.)
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader!!.surface, null, null
)
projection.registerCallback(object : MediaProjection.Callback() { projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() { override fun onStop() {
Log.i(TAG, "MediaProjection stopped by system") Log.i(TAG, "MediaProjection stopped by system")
@ -68,6 +64,13 @@ object ScreenCaptureService {
} }
}, null) }, null)
virtualDisplay = projection.createVirtualDisplay(
"ScreenTinker",
captureWidth, captureHeight, density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader!!.surface, null, null
)
Log.i(TAG, "MediaProjection started: ${captureWidth}x${captureHeight}") Log.i(TAG, "MediaProjection started: ${captureWidth}x${captureHeight}")
} }

View file

@ -5,6 +5,9 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.Signature
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
@ -17,6 +20,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.security.MessageDigest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class UpdateChecker(private val context: Context) { class UpdateChecker(private val context: Context) {
@ -33,8 +37,45 @@ class UpdateChecker(private val context: Context) {
// Check every 30 minutes // Check every 30 minutes
private val CHECK_INTERVAL = 30 * 60 * 1000L private val CHECK_INTERVAL = 30 * 60 * 1000L
private var installReceiverRegistered = false
// The PackageInstaller session reports its status (incl. STATUS_PENDING_USER_ACTION,
// which Android 13+ returns for non-device-owner installers) via this broadcast.
// Without handling it the committed session just stalls and the update never
// installs. On the action prompt we launch the confirm dialog; the accessibility
// service auto-confirms it on kiosks.
private fun ensureInstallReceiver() {
if (installReceiverRegistered) return
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
when (intent.getIntExtra(android.content.pm.PackageInstaller.EXTRA_STATUS, -999)) {
android.content.pm.PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirm != null) {
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try { context.startActivity(confirm); Log.i(TAG, "Launched install confirmation") }
catch (e: Exception) { Log.e(TAG, "Confirm launch failed: ${e.message}") }
}
}
android.content.pm.PackageInstaller.STATUS_SUCCESS -> Log.i(TAG, "Update installed successfully")
else -> Log.w(TAG, "Install status: ${intent.getStringExtra(android.content.pm.PackageInstaller.EXTRA_STATUS_MESSAGE)}")
}
}
}
val filter = IntentFilter("com.remotedisplay.player.INSTALL_COMPLETE")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter)
}
installReceiverRegistered = true
}
fun startPeriodicCheck() { fun startPeriodicCheck() {
stopPeriodicCheck() stopPeriodicCheck()
ensureInstallReceiver()
checkTimer = object : Runnable { checkTimer = object : Runnable {
override fun run() { override fun run() {
checkForUpdate() checkForUpdate()
@ -107,6 +148,20 @@ class UpdateChecker(private val context: Context) {
Log.i(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)") Log.i(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
// SECURITY (#5 review): never install an APK we didn't sign. The update
// is fetched from a server-supplied URL, often over cleartext with no
// pinning - a MITM or compromised server could otherwise return a
// malicious APK and get it silently installed (REQUEST_INSTALL_PACKAGES).
// Verify the downloaded APK is our package AND signed by the same key as
// the currently-installed app before installing. An attacker can't forge
// our signature, so this holds even over an untrusted transport.
if (!verifyApkSignature(apkFile)) {
Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)")
apkFile.delete()
return
}
Log.i(TAG, "APK signature verified against installed app - proceeding to install")
// Install the APK // Install the APK
handler.post { handler.post {
installApk(apkFile) installApk(apkFile)
@ -166,9 +221,15 @@ class UpdateChecker(private val context: Context) {
} }
} }
// #96 (install bug): the status PendingIntent must stay FLAG_MUTABLE so
// PackageInstaller can write EXTRA_STATUS back into it - but on Android 14+
// (target SDK 34+) a FLAG_MUTABLE PendingIntent with an *implicit* intent is
// disallowed and getBroadcast() throws, silently aborting every OTA on 14+.
// Make the intent explicit (setPackage) so mutable is allowed; it also keeps
// the broadcast to our own RECEIVER_NOT_EXPORTED receiver.
val pendingIntent = android.app.PendingIntent.getBroadcast( val pendingIntent = android.app.PendingIntent.getBroadcast(
context, sessionId, context, sessionId,
Intent("com.remotedisplay.player.INSTALL_COMPLETE"), Intent("com.remotedisplay.player.INSTALL_COMPLETE").setPackage(context.packageName),
android.app.PendingIntent.FLAG_MUTABLE android.app.PendingIntent.FLAG_MUTABLE
) )
session.commit(pendingIntent.intentSender) session.commit(pendingIntent.intentSender)
@ -179,6 +240,56 @@ class UpdateChecker(private val context: Context) {
} }
} }
// True only if the downloaded APK is this same package and shares a signing
// certificate with the installed app. Fail-closed on any error.
private fun verifyApkSignature(apkFile: File): Boolean {
return try {
val pm = context.packageManager
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, flags)
if (downloaded == null) {
Log.e(TAG, "Could not parse downloaded APK")
return false
}
if (downloaded.packageName != context.packageName) {
Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}")
return false
}
val installed = pm.getPackageInfo(context.packageName, flags)
val downloadedSigs = signingCertHashes(downloaded)
val installedSigs = signingCertHashes(installed)
if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) {
Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})")
return false
}
// Share at least one current signing certificate.
val match = downloadedSigs.any { it in installedSigs }
if (!match) Log.e(TAG, "APK signing certificate does not match installed app")
match
} catch (e: Exception) {
Log.e(TAG, "Signature verification error: ${e.message}", e)
false
}
}
private fun signingCertHashes(info: PackageInfo): Set<String> {
val sigs: Array<Signature>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.signingInfo?.apkContentsSigners
} else {
@Suppress("DEPRECATION") info.signatures
}
return sigs?.mapNotNull { sha256(it.toByteArray()) }?.toSet() ?: emptySet()
}
private fun sha256(bytes: ByteArray): String? {
return try {
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
null
}
}
private fun getAppVersion(): String { private fun getAppVersion(): String {
return try { return try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0" context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"

View file

@ -53,7 +53,16 @@ class WebSocketService : Service() {
super.onCreate() super.onCreate()
config = ServerConfig(this) config = ServerConfig(this)
deviceInfo = DeviceInfo(this) deviceInfo = DeviceInfo(this)
// #5: claim ONLY the mediaPlayback FGS type. The 2-arg startForeground
// claims every manifest-declared type, and on Android 14+ claiming
// mediaProjection without a consent token throws and kills the service at
// boot (the "app won't run on newer Android" symptom). Screen capture has
// its own mediaProjection-typed service (MediaProjectionService).
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
startForeground(1, createNotification(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(1, createNotification()) startForeground(1, createNotification())
}
// Keep CPU alive so the WebSocket connection stays alive in background // Keep CPU alive so the WebSocket connection stays alive in background
val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager
@ -65,6 +74,21 @@ class WebSocketService : Service() {
return START_STICKY return START_STICKY
} }
// Wrap every Socket.IO listener body in try/catch. A malformed payload from the server
// (or a transient state error during disconnect) used to surface as an unhandled
// exception on the Socket.IO IO thread and crash the whole app.
private fun Socket.safeOn(event: String, handler: (Array<Any?>) -> Unit): Socket {
on(event) { args ->
try {
@Suppress("UNCHECKED_CAST")
handler(args as Array<Any?>)
} catch (e: Throwable) {
Log.e("WebSocketService", "Listener for '$event' failed: ${e.message}", e)
}
}
return this
}
fun connect(serverUrl: String? = null) { fun connect(serverUrl: String? = null) {
val url = serverUrl ?: config.serverUrl val url = serverUrl ?: config.serverUrl
if (url.isEmpty()) { if (url.isEmpty()) {
@ -79,189 +103,221 @@ class WebSocketService : Service() {
forceNew = true forceNew = true
reconnection = true reconnection = true
reconnectionAttempts = Integer.MAX_VALUE reconnectionAttempts = Integer.MAX_VALUE
reconnectionDelay = 2000 // Exponential backoff: starts at 1s, doubles each attempt, capped at 60s,
reconnectionDelayMax = 10000 // ±50% jitter so a fleet doesn't reconnect in lockstep after a server blip.
reconnectionDelay = 1000
reconnectionDelayMax = 60_000
randomizationFactor = 0.5
timeout = 20000 timeout = 20000
} }
socket = IO.socket(URI.create("$url/device"), options).apply { socket = IO.socket(URI.create("$url/device"), options).apply {
on(Socket.EVENT_CONNECT) { safeOn(Socket.EVENT_CONNECT) {
Log.i("WebSocketService", "Connected to server") Log.i("WebSocketService", "Connected to server")
register() register()
} }
on(Socket.EVENT_DISCONNECT) { safeOn(Socket.EVENT_DISCONNECT) { args ->
Log.w("WebSocketService", "Disconnected from server") val reason = args.firstOrNull()?.toString() ?: "unknown"
Log.w("WebSocketService", "Disconnected from server: $reason")
// Stop heartbeat while disconnected; player keeps showing cached content.
// Socket.IO will reconnect automatically per the options above.
stopHeartbeat()
} }
on(Socket.EVENT_CONNECT_ERROR) { args -> safeOn(Socket.EVENT_CONNECT_ERROR) { args ->
Log.e("WebSocketService", "Connection error: ${args.firstOrNull()}") Log.e("WebSocketService", "Connection error: ${args.firstOrNull()}")
} }
on("device:registered") { args -> safeOn("device:registered") { args ->
val data = args[0] as JSONObject val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val newDeviceId = data.getString("device_id") val newDeviceId = data.optString("device_id", "")
if (newDeviceId.isEmpty()) {
Log.w("WebSocketService", "device:registered missing device_id")
return@safeOn
}
config.deviceId = newDeviceId config.deviceId = newDeviceId
// Persist device_token (issued on first register, or refreshed on reconnect) // Persist device_token (issued on first register, or refreshed on reconnect)
if (data.has("device_token")) { if (data.has("device_token")) {
config.deviceToken = data.getString("device_token") config.deviceToken = data.optString("device_token", "")
} }
Log.i("WebSocketService", "Registered as: $newDeviceId") Log.i("WebSocketService", "Registered as: $newDeviceId")
handler.post { onRegistered?.invoke(newDeviceId) } handler.post { try { onRegistered?.invoke(newDeviceId) } catch (e: Throwable) { Log.e("WebSocketService", "onRegistered cb: ${e.message}") } }
startHeartbeat() startHeartbeat()
} }
on("device:unpaired") { safeOn("device:unpaired") {
Log.w("WebSocketService", "Device not found on server - clearing credentials") Log.w("WebSocketService", "Device not found on server - clearing credentials")
config.clearDeviceCredentials() config.clearDeviceCredentials()
handler.post { onUnpaired?.invoke() } handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
} }
on("device:auth-error") { args -> safeOn("device:auth-error") { args ->
val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed" val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed"
Log.w("WebSocketService", "Device auth rejected: $msg — clearing credentials for re-pair") Log.w("WebSocketService", "Device auth rejected: $msg — clearing credentials for re-pair")
config.clearDeviceCredentials() config.clearDeviceCredentials()
handler.post { onUnpaired?.invoke() } handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
} }
on("device:paired") { args -> safeOn("device:paired") { args ->
val data = args[0] as JSONObject val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val id = data.getString("device_id") val id = data.optString("device_id", "")
val name = data.optString("name", "Display") val name = data.optString("name", "Display")
config.setPaired(true) config.setPaired(true)
config.deviceName = name config.deviceName = name
Log.i("WebSocketService", "Paired as: $name") Log.i("WebSocketService", "Paired as: $name")
handler.post { onPaired?.invoke(id, name) } handler.post { try { onPaired?.invoke(id, name) } catch (e: Throwable) { Log.e("WebSocketService", "onPaired cb: ${e.message}") } }
} }
on("device:playlist-update") { args -> safeOn("device:playlist-update") { args ->
Log.i("WebSocketService", "Playlist raw args: ${args.size} items, type=${args[0]?.javaClass?.name}, data=${args[0]}") val data = args.firstOrNull() as? JSONObject ?: run {
val data = args[0] as JSONObject Log.w("WebSocketService", "playlist-update with non-JSONObject payload: ${args.firstOrNull()}")
Log.i("WebSocketService", "Playlist update received, keys=${data.keys().asSequence().toList()}, assignments=${data.optJSONArray("assignments")?.length() ?: "null"}") return@safeOn
handler.post { onPlaylistUpdate?.invoke(data) } }
Log.i("WebSocketService", "Playlist update received, assignments=${data.optJSONArray("assignments")?.length() ?: "null"}")
handler.post { try { onPlaylistUpdate?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onPlaylistUpdate cb: ${e.message}") } }
} }
on("device:content-delete") { args -> safeOn("device:content-delete") { args ->
val data = args[0] as JSONObject val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val contentId = data.getString("content_id") val contentId = data.optString("content_id", "")
handler.post { onContentDelete?.invoke(contentId) } if (contentId.isNotEmpty()) {
handler.post { try { onContentDelete?.invoke(contentId) } catch (e: Throwable) { Log.e("WebSocketService", "onContentDelete cb: ${e.message}") } }
}
} }
on("device:screenshot-request") { safeOn("device:screenshot-request") {
captureAndSendScreenshot() captureAndSendScreenshot()
handler.post { onScreenshotRequest?.invoke() } handler.post { try { onScreenshotRequest?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onScreenshotRequest cb: ${e.message}") } }
} }
on("device:remote-start") { safeOn("device:remote-start") {
startScreenshotStream() startScreenshotStream()
handler.post { onRemoteStart?.invoke() } handler.post { try { onRemoteStart?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteStart cb: ${e.message}") } }
} }
on("device:remote-stop") { safeOn("device:remote-stop") {
stopScreenshotStream() stopScreenshotStream()
handler.post { onRemoteStop?.invoke() } handler.post { try { onRemoteStop?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteStop cb: ${e.message}") } }
} }
on("device:remote-touch") { args -> safeOn("device:remote-touch") { args ->
val data = args[0] as JSONObject val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val x = data.getDouble("x").toFloat() val x = data.optDouble("x", 0.0).toFloat()
val y = data.getDouble("y").toFloat() val y = data.optDouble("y", 0.0).toFloat()
val action = data.optString("action", "tap") val action = data.optString("action", "tap")
// Use AccessibilityService for system-wide touch (works on dialogs too)
val svc = PowerAccessibilityService.instance val svc = PowerAccessibilityService.instance
if (svc != null && action == "tap") { if (svc != null && action == "tap") {
handler.post { svc.injectTap(x, y) } handler.post { try { svc.injectTap(x, y) } catch (e: Throwable) { Log.e("WebSocketService", "injectTap: ${e.message}") } }
} else { } else {
handler.post { onRemoteTouch?.invoke(x, y, action) } handler.post { try { onRemoteTouch?.invoke(x, y, action) } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteTouch cb: ${e.message}") } }
} }
} }
on("device:remote-key") { args -> safeOn("device:remote-key") { args ->
val data = args[0] as JSONObject val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val keycode = data.getString("keycode") val keycode = data.optString("keycode", "")
// Always inject via shell (works even when app not in foreground) if (keycode.isEmpty()) return@safeOn
injectKey(keycode) injectKey(keycode)
handler.post { onRemoteKey?.invoke(keycode) } handler.post { try { onRemoteKey?.invoke(keycode) } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteKey cb: ${e.message}") } }
} }
on("device:command") { args -> safeOn("device:command") { args ->
val data = args[0] as JSONObject val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val type = data.getString("type") val type = data.optString("type", "")
if (type.isEmpty()) return@safeOn
val payload = data.optJSONObject("payload") val payload = data.optJSONObject("payload")
Log.i("WebSocketService", "Command received: $type") Log.i("WebSocketService", "Command received: $type")
// Handle system commands directly in the service
when (type) { when (type) {
"launch" -> { "launch" -> {
handler.post { handler.post {
try {
val intent = Intent(this@WebSocketService, MainActivity::class.java).apply { val intent = Intent(this@WebSocketService, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
} }
startActivity(intent) startActivity(intent)
Log.i("WebSocketService", "Launched MainActivity from service") Log.i("WebSocketService", "Launched MainActivity from service")
} catch (e: Throwable) { Log.e("WebSocketService", "launch cmd: ${e.message}") }
} }
} }
"settings" -> { "settings" -> {
handler.post { handler.post {
try {
val intent = Intent(android.provider.Settings.ACTION_SETTINGS).apply { val intent = Intent(android.provider.Settings.ACTION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
startActivity(intent) startActivity(intent)
Log.i("WebSocketService", "Opened system settings") } catch (e: Throwable) { Log.e("WebSocketService", "settings cmd: ${e.message}") }
} }
} }
"enable_system_capture" -> { "enable_system_capture" -> {
// Trigger MediaProjection permission request on device
handler.post { handler.post {
try {
com.remotedisplay.player.ScreenCapturePermissionActivity.requestPermission(this@WebSocketService) com.remotedisplay.player.ScreenCapturePermissionActivity.requestPermission(this@WebSocketService)
Log.i("WebSocketService", "Requesting system capture permission") } catch (e: Throwable) { Log.e("WebSocketService", "enable_system_capture: ${e.message}") }
} }
} }
"screen_off" -> { "screen_off" -> {
val a11y = PowerAccessibilityService.instance val a11y = PowerAccessibilityService.instance
if (a11y != null) { if (a11y != null) {
handler.post { a11y.lockScreen() } handler.post { try { a11y.lockScreen() } catch (e: Throwable) { Log.e("WebSocketService", "lockScreen: ${e.message}") } }
} else { } else {
Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() } catch (_: Exception) {} }.start() Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() } catch (_: Exception) {} }.start()
} }
} }
"screen_on" -> { "screen_on" -> {
// WAKEUP keyevent works from shell on most devices
Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() } catch (_: Exception) {} }.start() Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() } catch (_: Exception) {} }.start()
} }
else -> handler.post { onCommand?.invoke(type, payload) } "set_debug" -> {
val on = payload?.optBoolean("enabled", false) ?: false
// Point the sink at this socket, then flip the flag. When on,
// DebugLog.* mirrors player/zone lines to the dashboard.
com.remotedisplay.player.util.DebugLog.sink = { tag, level, msg ->
try {
socket?.emit("device:log", JSONObject().apply {
put("tag", tag); put("level", level); put("message", msg)
})
} catch (_: Throwable) {}
}
com.remotedisplay.player.util.DebugLog.enabled = on
Log.i("WebSocketService", "Remote debug logging ${if (on) "ENABLED" else "disabled"}")
com.remotedisplay.player.util.DebugLog.i("Debug", "Remote debug logging ${if (on) "ON" else "OFF"}")
}
else -> handler.post { try { onCommand?.invoke(type, payload) } catch (e: Throwable) { Log.e("WebSocketService", "onCommand cb: ${e.message}") } }
} }
} }
connect() connect()
} }
} catch (e: Exception) { } catch (e: Throwable) {
Log.e("WebSocketService", "Socket setup error: ${e.message}") Log.e("WebSocketService", "Socket setup error: ${e.message}", e)
} }
} }
private fun register() { private fun register() {
try {
val data = JSONObject().apply { val data = JSONObject().apply {
if (config.isProvisioned && config.isPaired) { if (config.isProvisioned && config.isPaired) {
put("device_id", config.deviceId) put("device_id", config.deviceId)
// Send device_token for authentication (may be empty for legacy devices)
val token = config.deviceToken val token = config.deviceToken
if (token.isNotEmpty()) { if (token.isNotEmpty()) {
put("device_token", token) put("device_token", token)
} }
} else { } else {
// Generate a pairing code if we don't have one
val pairingCode = (100000..999999).random().toString() val pairingCode = (100000..999999).random().toString()
put("pairing_code", pairingCode) put("pairing_code", pairingCode)
config.deviceId = "" // Will be set on registered event config.deviceId = ""
// Store pairing code temporarily
getSharedPreferences("remote_display", MODE_PRIVATE) getSharedPreferences("remote_display", MODE_PRIVATE)
.edit().putString("pairing_code", pairingCode).apply() .edit().putString("pairing_code", pairingCode).apply()
} }
put("device_info", deviceInfo.getDeviceInfo()) try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") }
put("fingerprint", deviceInfo.getFingerprint()) try { put("fingerprint", deviceInfo.getFingerprint()) } catch (e: Throwable) { Log.w("WebSocketService", "fingerprint: ${e.message}") }
} }
socket?.emit("device:register", data) socket?.emit("device:register", data)
} catch (e: Throwable) {
Log.e("WebSocketService", "register failed: ${e.message}", e)
}
} }
fun getPairingCode(): String { fun getPairingCode(): String {
@ -291,16 +347,17 @@ class WebSocketService : Service() {
fun requestPlaylistRefresh() { fun requestPlaylistRefresh() {
if (socket?.connected() != true || config.deviceId.isEmpty()) return if (socket?.connected() != true || config.deviceId.isEmpty()) return
Log.i("WebSocketService", "Requesting playlist refresh") Log.i("WebSocketService", "Requesting playlist refresh")
// Re-register triggers the server to send current playlist try {
val data = org.json.JSONObject().apply { val data = org.json.JSONObject().apply {
put("device_id", config.deviceId) put("device_id", config.deviceId)
val token = config.deviceToken val token = config.deviceToken
if (token.isNotEmpty()) { if (token.isNotEmpty()) put("device_token", token)
put("device_token", token) try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") }
}
put("device_info", deviceInfo.getDeviceInfo())
} }
socket?.emit("device:register", data) socket?.emit("device:register", data)
} catch (e: Throwable) {
Log.e("WebSocketService", "requestPlaylistRefresh failed: ${e.message}")
}
} }
private fun stopHeartbeat() { private fun stopHeartbeat() {
@ -310,11 +367,15 @@ class WebSocketService : Service() {
private fun sendHeartbeat() { private fun sendHeartbeat() {
if (socket?.connected() != true) return if (socket?.connected() != true) return
try {
val data = JSONObject().apply { val data = JSONObject().apply {
put("device_id", config.deviceId) put("device_id", config.deviceId)
put("telemetry", deviceInfo.getTelemetry()) try { put("telemetry", deviceInfo.getTelemetry()) } catch (e: Throwable) { Log.w("WebSocketService", "telemetry: ${e.message}") }
} }
socket?.emit("device:heartbeat", data) socket?.emit("device:heartbeat", data)
} catch (e: Throwable) {
Log.e("WebSocketService", "sendHeartbeat failed: ${e.message}")
}
} }
// Screenshot streaming from the service (works even when activity is paused) // Screenshot streaming from the service (works even when activity is paused)
@ -381,11 +442,13 @@ class WebSocketService : Service() {
fun sendScreenshot(imageBase64: String) { fun sendScreenshot(imageBase64: String) {
if (socket?.connected() != true) return if (socket?.connected() != true) return
try {
val data = JSONObject().apply { val data = JSONObject().apply {
put("device_id", config.deviceId) put("device_id", config.deviceId)
put("image_b64", imageBase64) put("image_b64", imageBase64)
} }
socket?.emit("device:screenshot", data) socket?.emit("device:screenshot", data)
} catch (e: Throwable) { Log.w("WebSocketService", "sendScreenshot: ${e.message}") }
} }
private fun injectKey(keycode: String) { private fun injectKey(keycode: String) {
@ -440,28 +503,32 @@ class WebSocketService : Service() {
fun sendContentAck(contentId: String, status: String) { fun sendContentAck(contentId: String, status: String) {
if (socket?.connected() != true) return if (socket?.connected() != true) return
try {
val data = JSONObject().apply { val data = JSONObject().apply {
put("device_id", config.deviceId) put("device_id", config.deviceId)
put("content_id", contentId) put("content_id", contentId)
put("status", status) put("status", status)
} }
socket?.emit("device:content-ack", data) socket?.emit("device:content-ack", data)
} catch (e: Throwable) { Log.w("WebSocketService", "sendContentAck: ${e.message}") }
} }
fun sendPlaybackState(contentId: String, positionSec: Float) { fun sendPlaybackState(contentId: String, positionSec: Float) {
if (socket?.connected() != true) return if (socket?.connected() != true) return
try {
val data = JSONObject().apply { val data = JSONObject().apply {
put("device_id", config.deviceId) put("device_id", config.deviceId)
put("current_content_id", contentId) put("current_content_id", contentId)
put("position_sec", positionSec) put("position_sec", positionSec)
} }
socket?.emit("device:playback-state", data) socket?.emit("device:playback-state", data)
} catch (e: Throwable) { Log.w("WebSocketService", "sendPlaybackState: ${e.message}") }
} }
fun disconnect() { fun disconnect() {
stopHeartbeat() stopHeartbeat()
socket?.disconnect() try { socket?.disconnect() } catch (e: Throwable) { Log.w("WebSocketService", "disconnect: ${e.message}") }
socket?.off() try { socket?.off() } catch (e: Throwable) { Log.w("WebSocketService", "off: ${e.message}") }
socket = null socket = null
} }

View file

@ -30,6 +30,9 @@ class DeviceInfo(private val context: Context) {
put("wifi_ssid", getWifiSSID()) put("wifi_ssid", getWifiSSID())
put("wifi_rssi", getWifiRSSI()) put("wifi_rssi", getWifiRSSI())
put("uptime_seconds", getUptimeSeconds()) put("uptime_seconds", getUptimeSeconds())
// #74/#75: OS timezone + UTC clock (effective-tz resolution + dashboard skew indicator)
put("timezone", java.util.TimeZone.getDefault().id)
put("device_utc", System.currentTimeMillis())
} }
} }

View file

@ -0,0 +1,25 @@
package com.remotedisplay.player.util
import android.util.Log
/**
* Lightweight player debug logger. Always writes to logcat; when remote debug is
* enabled (toggled from the dashboard device-detail screen via a `set_debug`
* command), it ALSO streams the line to the server over the device socket so it
* can be watched live without adb. Off by default; no network when disabled.
*/
object DebugLog {
@Volatile var enabled = false
// Set by WebSocketService: (tag, level, message) -> emit over the device socket.
@Volatile var sink: ((String, String, String) -> Unit)? = null
fun d(tag: String, msg: String) { Log.d(tag, msg); send(tag, "d", msg) }
fun i(tag: String, msg: String) { Log.i(tag, msg); send(tag, "i", msg) }
fun w(tag: String, msg: String) { Log.w(tag, msg); send(tag, "w", msg) }
fun e(tag: String, msg: String) { Log.e(tag, msg); send(tag, "e", msg) }
private fun send(tag: String, level: String, msg: String) {
if (!enabled) return
try { sink?.invoke(tag, level, msg) } catch (_: Throwable) {}
}
}

View file

@ -0,0 +1,94 @@
package com.remotedisplay.player.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import java.io.File
import java.net.URL
/**
* Safe bitmap loader. Reads dimensions first via inJustDecodeBounds, then decodes
* with an inSampleSize that scales the image down to the device's screen resolution.
* A 4K source image on a 1080p screen ends up as 1920x1080, not 3840x2160 keeps
* the bitmap under ~8 MB instead of ~33 MB.
*
* All exceptions, including OutOfMemoryError, return null so the caller can skip the
* item rather than crashing the whole app.
*/
object ImageLoader {
private const val TAG = "ImageLoader"
fun screenWidth(ctx: Context): Int = ctx.resources.displayMetrics.widthPixels
fun screenHeight(ctx: Context): Int = ctx.resources.displayMetrics.heightPixels
fun decodeFile(file: File, maxW: Int, maxH: Int): Bitmap? {
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
Log.w(TAG, "Invalid image dimensions for ${file.name}")
return null
}
val opts = BitmapFactory.Options().apply {
inSampleSize = calcSampleSize(bounds.outWidth, bounds.outHeight, maxW, maxH)
}
BitmapFactory.decodeFile(file.absolutePath, opts)
} catch (e: OutOfMemoryError) {
Log.e(TAG, "OOM decoding ${file.name}: ${e.message}")
null
} catch (e: Throwable) {
Log.e(TAG, "Failed to decode ${file.name}: ${e.message}")
null
}
}
fun decodeUrl(url: String, maxW: Int, maxH: Int): Bitmap? {
// Reject anything that isn't HTTP/HTTPS. URL.openConnection() otherwise
// happily handles file://, jar:, ftp:, etc. — which would let a server-supplied
// remote_url read local files off the device or talk to internal services.
val scheme = try { URL(url).protocol?.lowercase() } catch (_: Throwable) { null }
if (scheme != "http" && scheme != "https") {
Log.w(TAG, "Rejecting non-http(s) URL scheme: $scheme")
return null
}
return try {
val bytes = URL(url).openConnection().apply {
connectTimeout = 10_000
readTimeout = 30_000
}.getInputStream().use { it.readBytes() }
decodeBytes(bytes, maxW, maxH)
} catch (e: OutOfMemoryError) {
Log.e(TAG, "OOM downloading $url: ${e.message}")
null
} catch (e: Throwable) {
Log.e(TAG, "Failed to download $url: ${e.message}")
null
}
}
private fun decodeBytes(bytes: ByteArray, maxW: Int, maxH: Int): Bitmap? {
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val opts = BitmapFactory.Options().apply {
inSampleSize = calcSampleSize(bounds.outWidth, bounds.outHeight, maxW, maxH)
}
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
} catch (e: OutOfMemoryError) {
Log.e(TAG, "OOM decoding ${bytes.size} bytes: ${e.message}")
null
} catch (e: Throwable) {
Log.e(TAG, "Failed to decode ${bytes.size} bytes: ${e.message}")
null
}
}
private fun calcSampleSize(srcW: Int, srcH: Int, maxW: Int, maxH: Int): Int {
if (maxW <= 0 || maxH <= 0) return 1
var sample = 1
while (srcW / sample > maxW || srcH / sample > maxH) sample *= 2
return sample
}
}

View file

@ -0,0 +1,85 @@
package com.remotedisplay.player.util
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
/**
* Shared setup + helpers for the player's WebViews (zone widgets, fullscreen
* widgets, YouTube). Centralizes:
* - JS / DOM storage / autoplay-without-gesture,
* - mixed-content ALLOW (self-hosted servers are often http on the LAN; without
* this an https page embedding http - or vice versa - is silently blocked into
* a black broken-frame),
* - error/console logging piped to DebugLog so a failing web frame shows the
* real reason in the live debug panel instead of just a black broken-page view,
* - a YouTube embed that loads with a valid youtube.com origin (fixes Error 153).
*/
object WebViewSupport {
const val YT_BASE = "https://www.youtube.com"
// Base URL the embed page is loaded under (its referrer to YouTube). It must be
// a normal embedding site, NOT youtube.com itself — a page claiming to be
// youtube.com embedding a youtube.com iframe is rejected as an invalid embed
// context ("This video is unavailable / Error 152"). A real third-party domain
// is what legitimate embeds use.
const val EMBED_BASE = "https://screentinker.com"
fun configure(webView: WebView, tag: String) {
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
webView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
webView.webViewClient = object : WebViewClient() {
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
if (request?.isForMainFrame == true) {
DebugLog.e(tag, "WebView load error ${error?.errorCode} ${error?.description} url=${request.url}")
}
}
override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) {
if (request?.isForMainFrame == true) {
DebugLog.e(tag, "WebView HTTP ${errorResponse?.statusCode} url=${request.url}")
}
}
}
webView.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(msg: ConsoleMessage?): Boolean {
if (msg?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
DebugLog.w(tag, "JS error: ${msg.message()} @${msg.sourceId()}:${msg.lineNumber()}")
}
return super.onConsoleMessage(msg)
}
}
}
fun extractYoutubeId(url: String): String? {
val patterns = listOf(
Regex("""embed/([A-Za-z0-9_-]{6,})"""),
Regex("""[?&]v=([A-Za-z0-9_-]{6,})"""),
Regex("""youtu\.be/([A-Za-z0-9_-]{6,})""")
)
for (p in patterns) p.find(url)?.let { return it.groupValues[1] }
return null
}
/**
* HTML wrapper for a YouTube embed. Loaded via loadDataWithBaseURL(YT_BASE, ...)
* so the iframe has a valid youtube.com origin/referer (a bare loadUrl of the
* embed gives Error 153 "player misconfigured"). Returns null if no video id.
*/
fun youtubeEmbedHtml(url: String): String? {
val id = extractYoutubeId(url) ?: return null
val src = "$YT_BASE/embed/$id?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=$id&playsinline=1"
return "<!DOCTYPE html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
"<style>html,body{margin:0;padding:0;height:100%;background:#000;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style>" +
"</head><body><iframe src=\"$src\" allow=\"autoplay; encrypted-media\" allowfullscreen></iframe></body></html>"
}
}

View file

@ -1,12 +1,22 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <!-- Scrolls so content is always reachable on short screens; the pairing code
auto-sizes to fit any screen width (phones, TVs, HD sticks). -->
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:gravity="center" android:fillViewport="true"
android:background="#111827">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal|top"
android:orientation="vertical" android:orientation="vertical"
android:background="#111827" android:paddingHorizontal="32dp"
android:padding="48dp" android:paddingTop="24dp"
android:paddingBottom="24dp"
android:keepScreenOn="true"> android:keepScreenOn="true">
<TextView <TextView
@ -14,20 +24,21 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="RemoteDisplay" android:text="RemoteDisplay"
android:textColor="#3B82F6" android:textColor="#3B82F6"
android:textSize="36sp" android:textSize="22sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginBottom="8dp" /> android:layout_marginBottom="4dp" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Digital Signage Player" android:text="Digital Signage Player"
android:textColor="#94A3B8" android:textColor="#94A3B8"
android:textSize="16sp" android:textSize="14sp"
android:layout_marginBottom="48dp" /> android:layout_marginBottom="20dp" />
<!-- Server URL Section --> <!-- Server URL Section (hidden once paired so the code has room) -->
<LinearLayout <LinearLayout
android:id="@+id/serverSection"
android:layout_width="400dp" android:layout_width="400dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
@ -77,7 +88,7 @@
<!-- Pairing Section (shown after connection) --> <!-- Pairing Section (shown after connection) -->
<LinearLayout <LinearLayout
android:id="@+id/pairingSection" android:id="@+id/pairingSection"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center" android:gravity="center"
@ -91,19 +102,28 @@
android:textSize="16sp" android:textSize="16sp"
android:layout_marginBottom="12dp" /> android:layout_marginBottom="12dp" />
<!-- FIXED-height box: autosize fits the text inside the bounded box and
gravity center vertically centers it, so the digits are never
clipped (the earlier wrap_content height clipped the glyph bottoms).
24-64sp fills the width on phones/TVs/sticks. -->
<TextView <TextView
android:id="@+id/pairingCodeText" android:id="@+id/pairingCodeText"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="96dp"
android:text="------" android:text="------"
android:textColor="#3B82F6" android:textColor="#3B82F6"
android:textSize="64sp"
android:textStyle="bold" android:textStyle="bold"
android:fontFamily="monospace" android:fontFamily="monospace"
android:letterSpacing="0.3" /> android:letterSpacing="0.3"
android:maxLines="1"
android:gravity="center"
app:autoSizeTextType="uniform"
app:autoSizeMinTextSize="24sp"
app:autoSizeMaxTextSize="64sp"
app:autoSizeStepGranularity="2sp" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Enter this code in the dashboard to pair this display" android:text="Enter this code in the dashboard to pair this display"
android:textColor="#64748B" android:textColor="#64748B"
@ -118,6 +138,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="#94A3B8" android:textColor="#94A3B8"
android:textSize="14sp" android:textSize="14sp"
android:gravity="center"
android:layout_marginTop="16dp" /> android:layout_marginTop="16dp" />
</LinearLayout> </LinearLayout>
</ScrollView>

View file

@ -6,31 +6,42 @@
android:gravity="center" android:gravity="center"
android:orientation="vertical" android:orientation="vertical"
android:background="#111827" android:background="#111827"
android:padding="48dp" android:padding="12dp"
android:keepScreenOn="true"> android:keepScreenOn="true">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="RemoteDisplay Setup" android:text="RemoteDisplay Setup"
android:textColor="#3B82F6" android:textColor="#3B82F6"
android:textSize="32sp" android:textSize="12sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginBottom="8dp" /> android:layout_marginBottom="3dp" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Enable these permissions for full remote control" android:text="Enable these permissions for full remote control"
android:textColor="#94A3B8" android:textColor="#94A3B8"
android:textSize="16sp" android:textSize="9sp"
android:layout_marginBottom="40dp" /> android:layout_marginBottom="8dp" />
<LinearLayout <LinearLayout
android:layout_width="500dp" android:layout_width="420dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:layout_marginBottom="32dp"> android:layout_marginBottom="8dp">
<!-- Accessibility Service --> <!-- Accessibility Service -->
<LinearLayout <LinearLayout
@ -38,7 +49,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:layout_marginBottom="16dp"> android:layout_marginBottom="5dp">
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
@ -51,7 +62,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Accessibility Service" android:text="Accessibility Service"
android:textColor="#F1F5F9" android:textColor="#F1F5F9"
android:textSize="18sp" android:textSize="11sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
@ -59,7 +70,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Required for remote control (Home, Back, touch, gestures)" android:text="Required for remote control (Home, Back, touch, gestures)"
android:textColor="#64748B" android:textColor="#64748B"
android:textSize="13sp" /> android:textSize="8sp" />
</LinearLayout> </LinearLayout>
<TextView <TextView
@ -68,20 +79,24 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="OFF" android:text="OFF"
android:textColor="#EF4444" android:textColor="#EF4444"
android:textSize="14sp" android:textSize="9sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginEnd="12dp" /> android:layout_marginEnd="12dp" />
<Button <Button
android:id="@+id/enableAccessibilityBtn" android:id="@+id/enableAccessibilityBtn"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="40dp" android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable" android:text="Enable"
android:textColor="#FFFFFF" android:textColor="#FFFFFF"
android:textSize="14sp" android:textSize="9sp"
android:background="@drawable/button_primary" android:background="@drawable/button_primary"
android:paddingStart="20dp" android:paddingStart="12dp"
android:paddingEnd="20dp" /> android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout> </LinearLayout>
<!-- Display Over Other Apps --> <!-- Display Over Other Apps -->
@ -90,7 +105,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:layout_marginBottom="16dp"> android:layout_marginBottom="5dp">
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
@ -103,15 +118,15 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Install Unknown Apps" android:text="Install Unknown Apps"
android:textColor="#F1F5F9" android:textColor="#F1F5F9"
android:textSize="18sp" android:textSize="11sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Required for automatic OTA updates" android:text="OTA updates — only our signature-verified builds install"
android:textColor="#64748B" android:textColor="#64748B"
android:textSize="13sp" /> android:textSize="8sp" />
</LinearLayout> </LinearLayout>
<TextView <TextView
@ -120,20 +135,24 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="OFF" android:text="OFF"
android:textColor="#EF4444" android:textColor="#EF4444"
android:textSize="14sp" android:textSize="9sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginEnd="12dp" /> android:layout_marginEnd="12dp" />
<Button <Button
android:id="@+id/enableInstallBtn" android:id="@+id/enableInstallBtn"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="40dp" android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable" android:text="Enable"
android:textColor="#FFFFFF" android:textColor="#FFFFFF"
android:textSize="14sp" android:textSize="9sp"
android:background="@drawable/button_primary" android:background="@drawable/button_primary"
android:paddingStart="20dp" android:paddingStart="12dp"
android:paddingEnd="20dp" /> android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout> </LinearLayout>
<!-- Notification Permission (Android 13+) --> <!-- Notification Permission (Android 13+) -->
@ -143,7 +162,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:layout_marginBottom="16dp" android:layout_marginBottom="5dp"
android:visibility="gone"> android:visibility="gone">
<LinearLayout <LinearLayout
@ -157,7 +176,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Notifications" android:text="Notifications"
android:textColor="#F1F5F9" android:textColor="#F1F5F9"
android:textSize="18sp" android:textSize="11sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
@ -165,7 +184,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Required for background service" android:text="Required for background service"
android:textColor="#64748B" android:textColor="#64748B"
android:textSize="13sp" /> android:textSize="8sp" />
</LinearLayout> </LinearLayout>
<TextView <TextView
@ -174,33 +193,215 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="OFF" android:text="OFF"
android:textColor="#EF4444" android:textColor="#EF4444"
android:textSize="14sp" android:textSize="9sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginEnd="12dp" /> android:layout_marginEnd="12dp" />
<Button <Button
android:id="@+id/enableNotificationBtn" android:id="@+id/enableNotificationBtn"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="40dp" android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable" android:text="Enable"
android:textColor="#FFFFFF" android:textColor="#FFFFFF"
android:textSize="14sp" android:textSize="9sp"
android:background="@drawable/button_primary" android:background="@drawable/button_primary"
android:paddingStart="20dp" android:paddingStart="12dp"
android:paddingEnd="20dp" /> android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
<!-- Launch on boot / full-screen (Android 14+ restricts USE_FULL_SCREEN_INTENT) -->
<LinearLayout
android:id="@+id/fullscreenRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="5dp"
android:visibility="visible">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Launch on Boot"
android:textColor="#F1F5F9"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Required to auto-start the display after power-on"
android:textColor="#64748B"
android:textSize="8sp" />
</LinearLayout>
<TextView
android:id="@+id/fullscreenStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableFullscreenBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
<!-- Battery optimization exemption (boot + run reliability on OEM/TV boxes) -->
<LinearLayout
android:id="@+id/batteryRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="5dp"
android:visibility="visible">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Background Activity"
android:textColor="#F1F5F9"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Keep running and auto-start reliably"
android:textColor="#64748B"
android:textSize="8sp" />
</LinearLayout>
<TextView
android:id="@+id/batteryStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableBatteryBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
<!-- Display over other apps: alternate boot-launch path (works where you
can't set a launcher, e.g. Android TV). -->
<LinearLayout
android:id="@+id/overlayRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="5dp"
android:visibility="visible">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Display Over Apps"
android:textColor="#F1F5F9"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Lets the display launch itself on boot (TV boxes)"
android:textColor="#64748B"
android:textSize="8sp" />
</LinearLayout>
<TextView
android:id="@+id/overlayStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableOverlayBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<Button <Button
android:id="@+id/continueBtn" android:id="@+id/continueBtn"
android:layout_width="500dp" android:layout_width="420dp"
android:layout_height="48dp" android:layout_height="wrap_content"
android:minHeight="0dp"
android:text="Continue to Setup" android:text="Continue to Setup"
android:textColor="#FFFFFF" android:textColor="#FFFFFF"
android:textSize="16sp" android:textSize="11sp"
android:textStyle="bold" android:textStyle="bold"
android:background="@drawable/button_primary" /> android:background="@drawable/button_primary"
android:paddingTop="10dp"
android:paddingBottom="10dp" />
<TextView <TextView
android:id="@+id/skipText" android:id="@+id/skipText"
@ -208,8 +409,11 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Skip (remote control features will be limited)" android:text="Skip (remote control features will be limited)"
android:textColor="#64748B" android:textColor="#64748B"
android:textSize="13sp" android:textSize="8sp"
android:layout_marginTop="16dp" android:layout_marginTop="6dp"
android:padding="8dp" /> android:padding="8dp" />
</LinearLayout>
</ScrollView>
</LinearLayout> </LinearLayout>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay nutzt die Bedienungshilfen, um Fernsteuerung der Stromzufuhr und Systemnavigation zu ermöglichen.</string>
<string name="nothing_scheduled">Derzeit ist nichts geplant</string>
<string name="waiting_for_content">Warte auf Inhalte…</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay usa accesibilidad para habilitar el control remoto de encendido y la navegación del sistema.</string>
<string name="nothing_scheduled">No hay nada programado en este momento</string>
<string name="waiting_for_content">Esperando contenido…</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay utilise l\'accessibilité pour activer les contrôles d\'alimentation à distance et la navigation système.</string>
<string name="nothing_scheduled">Rien de programmé pour le moment</string>
<string name="waiting_for_content">En attente de contenu…</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Hindi: same as English by design (matches web hi.js skeleton).
Replace with native-reviewed translations before publicizing. -->
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay usa acessibilidade para habilitar controles remotos de energia e navegação do sistema.</string>
<string name="nothing_scheduled">Nada programado no momento</string>
<string name="waiting_for_content">Aguardando conteúdo…</string>
</resources>

View file

@ -2,4 +2,6 @@
<resources> <resources>
<string name="app_name">RemoteDisplay</string> <string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string> <string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string>
<string name="nothing_scheduled">Nothing scheduled right now</string>
<string name="waiting_for_content">Waiting for content…</string>
</resources> </resources>

View file

@ -0,0 +1,50 @@
package com.remotedisplay.player.player
import com.google.gson.JsonParser
import org.junit.Assert.assertEquals
import org.junit.Test
import java.io.File
import java.time.Instant
/**
* Drift guard (#74/#75): the Kotlin evaluator must agree with the SHARED contract
* at shared/schedule-vectors.json - the SAME file the JS server, web player, and
* Tizen player are held to. No snapshot is taken: the test task points
* `scheduleVectors` at the single source (see app/build.gradle.kts), so any future
* ScheduleEval.kt change that breaks a vector fails CI.
*/
class ScheduleEvalTest {
@Test
fun conformsToSharedVectors() {
val path = System.getProperty("scheduleVectors")
?: error("scheduleVectors system property not set (configured in app/build.gradle.kts)")
val vectors = JsonParser.parseString(File(path).readText()).asJsonObject.getAsJsonArray("vectors")
val failures = StringBuilder()
var count = 0
for (el in vectors) {
val v = el.asJsonObject
val blocks = v.getAsJsonArray("blocks").map { b ->
val o = b.asJsonObject
ScheduleEval.Block(
days = o.getAsJsonArray("days").map { it.asInt }.toSet(),
start = o.get("start").asString,
end = o.get("end").asString,
startDate = o.get("start_date").let { if (it.isJsonNull) null else it.asString },
endDate = o.get("end_date").let { if (it.isJsonNull) null else it.asString }
)
}
val utcMs = Instant.parse(v.get("utc_now").asString).toEpochMilli()
val got = ScheduleEval.isItemActiveNow(blocks, utcMs, v.get("timezone").asString)
val expected = v.get("expected").asBoolean
count++
if (got != expected) {
failures.append("\n[${v.get("utc_now").asString} ${v.get("timezone").asString}] " +
"expected $expected got $got :: ${v.get("description").asString}")
}
}
println("Kotlin JUnit schedule vectors: ${count - failures.count { it == '\n' }}/$count passed")
assertEquals("schedule vectors failed:$failures", 0, failures.length)
}
}

View file

@ -0,0 +1,33 @@
# Example docker-compose for self-hosting ScreenTinker.
# cp docker-compose.example.yml docker-compose.yml # then edit to taste
# The container serves plain HTTP on 3001 - front it with a TLS-terminating
# reverse proxy or Cloudflare in production.
services:
screentinker:
image: ghcr.io/screentinker/screentinker:latest
# ...or build from source instead of pulling:
# build: .
restart: unless-stopped
ports:
- "3001:3001"
environment:
SELF_HOSTED: "true" # first registered user becomes admin, no billing
# JWT_SECRET: "set-a-long-random-string" # else one is generated under /data/certs
# DISABLE_HOMEPAGE: "true" # redirect / to the app instead of the landing page
volumes:
- st-data:/data # db, uploads, and the jwt secret persist here
# To enable OTA APK downloads, mount a built APK read-only:
# - ./ScreenTinker.apk:/data/ScreenTinker.apk:ro
healthcheck:
# image is node:20-slim (no curl) - use node's built-in fetch
test: ["CMD", "node", "-e", "fetch('http://localhost:3001/api/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s # first boot runs db migrations
# Locked out of the admin account? Reset it with:
# docker exec -it screentinker node scripts/reset-admin.js
volumes:
st-data:

View file

@ -0,0 +1,137 @@
# Android Player — Troubleshooting & Recovery
Practical runbook for the RemoteDisplay / ScreenTinker Android player
(package `com.remotedisplay.player`, shown on the device as **RemoteDisplay**).
---
## Symptom: player stuck on "Connecting to server"
The UI sits on **"Connecting to server…"** and never pairs/plays. In `logcat`
you'll see this repeating every few seconds:
```
E WebSocketService: Connection error: io.socket.engineio.client.EngineIOException: xhr poll error
```
`xhr poll error` is a **transport-level** failure — the Socket.IO client can't
even open an HTTP connection to the configured server. It is **not** an auth
rejection and **not** a code crash (those happen *after* the socket connects).
### What it almost always means
The player's stored **server URL points at a host it can no longer reach.**
Most common causes, in order:
1. **Server moved / IP changed.** The device was provisioned against a local
dev box (`http://192.168.x.x:3000`) and that machine's IP changed or it's
on a different network now.
2. **Local dev server is down.** `remotedisplay.service` isn't running.
3. **No internet route.** The device's Wi-Fi genuinely can't reach the
internet (only relevant if it points at `https://screentinker.com`).
### Quick triage (no device access needed)
```bash
# Is the intended server even up?
curl -s -m 8 -o /dev/null -w "%{http_code}\n" https://screentinker.com/ # expect 200
# Local dev server running?
systemctl is-active remotedisplay.service
```
If the target server is up and on the **same LAN** as the device, the player
*should* connect once it's pointed there — so the fix is re-pointing the device.
> An APK upgrade does **not** cause this. `adb install -r` preserves app data,
> so the stored server URL survives the upgrade. Cleartext (`http://`) is
> allowed (`usesCleartextTraffic="true"` in the manifest), so upgrading does
> not block local servers either.
---
## Fix: re-point the player to a different server
The app only shows its **setup screen** when it is *not provisioned/paired*
(`MainActivity`: `if (!config.isProvisioned || !config.isPaired) -> ProvisioningActivity`).
So to change servers you must reset that state. Two ways:
### A. On the phone, no tools (most reliable)
1. **Settings → Apps → RemoteDisplay → Storage → Clear data.**
This wipes the stale server URL and pairing. (Cached content is cleared too;
it re-downloads after pairing — no harm.)
2. Reopen **RemoteDisplay** → the setup screen appears.
3. Enter the server URL, e.g. **`https://screentinker.com`** → tap **Connect**.
4. It shows a **6-digit pairing code**.
5. In the dashboard (e.g. screentinker.com), pair a device with that code.
The phone flips to "Paired as: …" and starts playing.
> After **Clear data**, the **Accessibility** permission the app uses for
> remote power/navigation is also reset. Re-enable it if you need remote
> reboot/screen control: Settings → Accessibility → RemoteDisplay → On.
### B. Via adb (if you have a working connection)
```bash
D=<ip:port>
# Option 1: reset provisioning the same way "Clear data" does
adb -s $D shell pm clear com.remotedisplay.player
adb -s $D shell monkey -p com.remotedisplay.player -c android.intent.category.LAUNCHER 1
# Option 2 (inspect first): read the currently-configured server URL
# NOTE: release builds are NOT debuggable, so `run-as` returns nothing and
# you cannot read /data/data/.../shared_prefs without root. Prefer Clear data.
```
---
## Connecting adb over Wi-Fi (Android 11+ Wireless Debugging)
Used to drive the device for installs/log capture. Ports here are **per-session
and change** when wireless debugging is toggled or the device reboots.
1. On device: **Developer options → Wireless debugging → On.**
2. **Pair** (one-time per host): tap *"Pair device with pairing code"*. It shows
a **pairing port** (different from the connect port) and a **6-digit code**:
```bash
adb pair <ip>:<pairing-port> <6-digit-code>
```
3. **Connect** using the **"IP address & Port"** from the *main* Wireless
debugging screen (the *connect* port, not the pairing port):
```bash
adb connect <ip>:<connect-port>
```
### Finding the ports when the UI/mDNS won't tell you
mDNS discovery (`adb mdns services`) **only works on the same L2 subnet**; it
won't cross a router. If the device is a hop away, scan for the open ports:
```bash
nmap -p 30000-50000 --open -T4 <ip> | grep open
```
The **connect** and **pairing** ports are random in the high range and churn;
the pairing port only exists while the pairing dialog is open.
### Gotchas learned the hard way
- **Be on the same subnet.** A wireless-debug *connect* port that is TCP-open
from across a router can still refuse the adb/TLS handshake. Pairing tolerates
routing; connecting often does not. Put your machine on the **same /24** as
the device.
- **Do NOT run `adb root` over a wireless connection.** It restarts `adbd` in
root mode, which **drops the TLS connection and stops re-binding the connect
port** — the phone keeps *displaying* the old port but it's refused. Recovery
is a **phone reboot** (or `adb unroot`, which you can't reach because you're
disconnected). Release builds aren't debuggable anyway, so root buys you
little here — prefer **Clear data** for config resets.
- After a reboot or a wireless-debugging toggle, the connect port **changes**
re-read it from the device and reconnect (pairing usually persists).
---
## Reference: where things live
| Thing | Location |
|---|---|
| Package id | `com.remotedisplay.player` |
| Display name | RemoteDisplay |
| Server URL entry | `ProvisioningActivity` (`R.id.serverUrlInput`) |
| Routing to setup | `MainActivity``if (!isProvisioned || !isPaired)` |
| Connection client | `service/WebSocketService.kt` (Socket.IO) |
| Cleartext allowed | `AndroidManifest.xml``usesCleartextTraffic="true"` |
| Build a signed APK | `KEYSTORE_PASSWORD=… KEY_PASSWORD=… ./gradlew assembleRelease` |
| APK output | `android/app/build/outputs/apk/release/app-release.apk` |

175
docs/local-ai-setup.md Normal file
View file

@ -0,0 +1,175 @@
# Local AI for the Content Designer
The **Content Designer → ✨ AI generate** feature turns a text prompt into a finished
sign: the layout and copy come from an LLM, and (optionally) the background /
foreground imagery comes from an image model. ScreenTinker is **bring-your-own**:
you point each workspace at an **OpenAI-compatible** text endpoint and an image
endpoint of your choice. Nothing is sent to us, and the operator pays no AI costs.
This guide sets up a fully **local, free** stack:
- **Text / layout** → [Ollama](https://ollama.com) (OpenAI-compatible)
- **Images** → [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp) server (OpenAI-compatible)
Prefer the cloud? Skip to [Using OpenAI instead](#using-openai-instead).
> [!IMPORTANT]
> To use **localhost / LAN** AI endpoints, your instance must run with
> **`SELF_HOSTED=true`**. ScreenTinker blocks private/internal addresses for the
> AI endpoints (SSRF protection) unless it is in self-hosted mode. See
> [Enable self-hosted mode](#1-enable-self-hosted-mode).
---
## 1. Enable self-hosted mode
The AI endpoint config is gated by an SSRF guard. On a self-hosted box this guard
is relaxed so you can point at `localhost`. Set the env var:
```bash
# systemd: drop-in (recommended)
sudo mkdir -p /etc/systemd/system/screentinker.service.d
printf '[Service]\nEnvironment=SELF_HOSTED=true\n' | sudo tee /etc/systemd/system/screentinker.service.d/selfhosted.conf
sudo systemctl daemon-reload && sudo systemctl restart screentinker
```
(Or `SELF_HOSTED=true npm start` for a manual run.)
---
## 2. Text / layout model — Ollama
```bash
# Install (use a recent build — 0.30+ is required for NVIDIA 50-series / Blackwell)
curl -fsSL https://ollama.com/install.sh | sh
# Pull a model. 8B is a good size/quality balance for signage copy.
ollama pull llama3.1:8b
# Confirm it's loaded on the GPU
ollama ps
```
Ollama exposes an OpenAI-compatible API at **`http://localhost:11434/v1`**. No API
key is required (any value works).
In **Designer → ⚙ AI settings**:
| Field | Value |
|---|---|
| Endpoint base URL | `http://localhost:11434/v1` |
| Model | `llama3.1:8b` (or click **Load models**) |
| API key | *(leave blank)* |
That alone enables AI generation (text + shapes). Add images below.
---
## 3. Image model — stable-diffusion.cpp (Vulkan)
We use the prebuilt **stable-diffusion.cpp** server. Its `--backend` runs on
**Vulkan**, which works on modern NVIDIA GPUs even where CUDA/PyTorch (ComfyUI)
fails to initialize — see [GPU notes](#gpu-notes--troubleshooting).
```bash
# 1. Grab the prebuilt server from the releases page and pick the variant for
# your GPU (…-vulkan.zip works broadly; cuda / rocm builds also exist):
# https://github.com/leejet/stable-diffusion.cpp/releases
mkdir -p ~/sd-server && cd ~/sd-server
unzip ~/Downloads/sd-*-vulkan.zip # -> sd-server, sd-cli, libstable-diffusion.so
# 2. A checkpoint. SDXL base is a solid default (~6.5 GB):
mkdir -p models
curl -L -o models/sd_xl_base_1.0.safetensors \
https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors
# 3. Find your GPU's Vulkan device index, then run the server.
# The startup log prints "Found N Vulkan devices" — note the index of your
# discrete GPU (an Intel/AMD iGPU is often device 0, the dGPU device 1).
LD_LIBRARY_PATH=~/sd-server ~/sd-server/sd-server \
-m ~/sd-server/models/sd_xl_base_1.0.safetensors \
--backend vulkan1 --listen-port 7860
```
The server is OpenAI-compatible at **`http://localhost:7860/v1`**
(`POST /v1/images/generations`). Smoke test:
```bash
curl -s http://localhost:7860/v1/images/generations \
-H 'Content-Type: application/json' \
-d '{"prompt":"a cozy cafe interior, no text","size":"1024x576","response_format":"b64_json"}' \
| head -c 80
```
In **Designer → ⚙ AI settings → AI images**:
| Field | Value |
|---|---|
| Image provider | **Stable Diffusion — local (sd.cpp)** |
| Image endpoint URL | `http://localhost:7860/v1` |
| Image model | *(leave blank — uses the loaded checkpoint)* |
| Image API key | *(leave blank)* |
Now a prompt produces a full sign: an atmospheric background, crisp text on top,
and an optional foreground graphic.
### Run it as a service (recommended)
```ini
# /etc/systemd/system/sd-server.service
[Unit]
Description=stable-diffusion.cpp image server
After=network.target
[Service]
User=youruser
Environment=LD_LIBRARY_PATH=/home/youruser/sd-server
ExecStart=/home/youruser/sd-server/sd-server -m /home/youruser/sd-server/models/sd_xl_base_1.0.safetensors --backend vulkan1 --listen-port 7860
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload && sudo systemctl enable --now sd-server
```
> **VRAM:** the server keeps the checkpoint resident (~6.5 GB for SDXL). The app
> requests modest sizes (1024×576 background, 768×768 foreground) so it fits
> alongside the LLM on a single ~16 GB+ GPU. Larger sizes need a tiled VAE
> (`--vae-tiling`) or more VRAM. ComfyUI works too — set the provider to
> **ComfyUI** and point at `http://localhost:8188`.
---
## Using OpenAI instead
No local hardware? Use the cloud (you pay OpenAI directly):
- **Text:** endpoint `https://api.openai.com/v1`, model e.g. `gpt-4o-mini`, paste your key.
- **Images:** provider **OpenAI / OpenAI-compatible**, endpoint `https://api.openai.com/v1`,
model e.g. `gpt-image-1`.
If your **text** endpoint is local (no key) but **images** are OpenAI, put the
OpenAI key in the separate **Image API key** field. When that field is blank, the
image endpoint reuses the main API key.
---
## GPU notes / troubleshooting
- **NVIDIA 50-series (Blackwell):** CUDA compute can fail to initialize for
PyTorch-based tools (ComfyUI) with `CUDA unknown error`, even though
`nvidia-smi` works. **Vulkan** does work — which is why this guide uses Ollama
(Vulkan) and stable-diffusion.cpp (Vulkan). Use a recent Ollama (0.30+).
- **Wrong/slow device:** if generation is CPU-slow, the tool picked the wrong
Vulkan device. Check the startup log's device list and set `--backend vulkanN`
(sd.cpp) accordingly; Ollama honours `GGML_VK_VISIBLE_DEVICES`.
- **`Endpoint URL not allowed`** when saving AI settings → the instance is not in
self-hosted mode. See [step 1](#1-enable-self-hosted-mode).
- **Images time out** → a cold or under-powered model. Try a smaller checkpoint
(e.g. SD 1.5) or fewer steps; first request also pays the model-load cost.
- **Publishing a sign with images** embeds the generated images in the widget,
so configs can be a few MB each. That's expected today.

View file

@ -0,0 +1,615 @@
# ScreenTinker Multi-Tenancy / Reseller Design (V1)
Status: design approved 2026-05-11. Implementation begins Phase 1 on approval of this doc.
## 1. Mental model
Today every user is the root of their own data. Teams give shared scope inside one user. There is no layer above that.
V1 adds two layers:
```
platform (the hosted screentinker.com instance, or one self-hosted install)
organization (a reseller or a customer paying us; owns a Stripe sub)
workspace (a client of the reseller; what was previously a Team)
device | content | playlist | layout | widget | schedule | video_wall | ...
```
- An **organization** is a billing/admin entity. Resellers run an org with many workspaces. Direct customers run an org with one workspace.
- A **workspace** is a tenant. Data inside is isolated from siblings. Equivalent to today's `teams` row, just parented by an org.
- Workspaces are the unit of UI tenancy: when you log in, you are "in" exactly one workspace at a time. The workspace picker switches context.
`teams` collapses into `workspaces`. `team_members` collapses into `workspace_members`. No nested teams inside workspaces in V1.
## 2. Roles
| Role | Scope | Powers |
| --- | --- | --- |
| `platform_admin` | platform (one or two rows) | sees everything across all orgs. Replaces today's `superadmin`. Hosted operator only. |
| `org_owner` | one org | full control of the org and every workspace inside, owns the Stripe subscription, can delete the org. |
| `org_admin` | one org | same as `org_owner` minus billing and delete-org. Suitable for reseller staff. |
| `workspace_admin` | one workspace | full control of one workspace: users, devices, content, playlists, branding. |
| `workspace_editor` | one workspace | create/edit content, devices, playlists, layouts, schedules. No user invites, no branding. |
| `workspace_viewer` | one workspace | read-only. |
Notes:
- Today's `users.role = 'admin'` (intermediate hosted role) is dropped. Existing rows get migrated to `org_admin` of their migrated org. See section 7.
- `workspace_owner` and `workspace_admin` collapse into a single `workspace_admin` role.
- A single user can hold roles in multiple orgs and multiple workspaces (multi-org membership). Memberships are stored in two join tables (see section 3).
### Permission check layering
Resolution order on every request, top wins:
1. `platform_admin` on the user row -> allow.
2. `org_owner` or `org_admin` on the user-in-this-org membership -> allow within that org's workspaces.
3. `workspace_admin` / `editor` / `viewer` on the user-in-this-workspace membership -> allow within that one workspace at the role level.
4. Otherwise -> 403.
Code shape (pseudocode, not code):
```
function can(user, action, target) {
if (user.role === 'platform_admin') return true;
const orgRole = orgRoleOf(user.id, target.organization_id);
if (orgRole === 'org_owner') return true;
if (orgRole === 'org_admin' && !ORG_OWNER_ONLY.has(action)) return true;
const wsRole = workspaceRoleOf(user.id, target.workspace_id);
return roleAllows(wsRole, action);
}
```
`ORG_OWNER_ONLY = { 'billing.write', 'org.delete', 'workspace.delete' }`.
## 3. Schema
### 3.1 New tables
```sql
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE, -- v2 subdomain hook
owner_user_id TEXT NOT NULL REFERENCES users(id),
plan_id TEXT DEFAULT 'free' REFERENCES plans(id),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
subscription_status TEXT DEFAULT 'active',
subscription_ends INTEGER,
-- subscription lifecycle (section 8)
grace_period_ends INTEGER, -- nullable; set when sub fails or cancels at period end
locked_at INTEGER, -- nullable; set when grace expires
-- branding defaults applied to new workspaces in this org
default_brand_name TEXT,
default_logo_url TEXT,
default_primary_color TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS organization_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'org_admin', -- 'org_owner' | 'org_admin'
invited_by TEXT REFERENCES users(id),
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(organization_id, user_id)
);
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT, -- v2 subdomain hook; unique within org
created_by TEXT REFERENCES users(id),
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(organization_id, slug)
);
CREATE TABLE IF NOT EXISTS workspace_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'workspace_viewer', -- 'workspace_admin' | 'workspace_editor' | 'workspace_viewer'
invited_by TEXT REFERENCES users(id),
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(workspace_id, user_id)
);
CREATE TABLE IF NOT EXISTS workspace_invites (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'workspace_viewer',
invited_by TEXT NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
```
### 3.2 Existing-table changes
Every per-tenant resource gets a `workspace_id`. The legacy `user_id` column stays (nullable) and represents "created by"; the legacy `team_id` column stays for one release as a compatibility shim, then drops in V2.
| Table | Adds | Notes |
| --- | --- | --- |
| `devices` | `workspace_id TEXT REFERENCES workspaces(id)` | required for new rows; legacy `user_id` becomes nullable created_by. |
| `content` | `workspace_id` | same. |
| `playlists` | `workspace_id` | same. |
| `layouts` | `workspace_id` | same. |
| `widgets` | `workspace_id` | same. `user_id IS NULL` ("public") rows stay platform-level templates owned by `platform_admin`. |
| `schedules` | `workspace_id` | same. |
| `video_walls` | `workspace_id` | same. |
| `device_groups` | `workspace_id` | same. |
| `white_labels` | `workspace_id TEXT REFERENCES workspaces(id)` (keyed by workspace, not user). | Org-level defaults live on `organizations.default_*`. |
| `activity_log` | `organization_id`, `workspace_id`, `acting_user_id`, `was_acting_as` | both org and workspace since some actions are org-scoped (billing). `acting_user_id` records the reseller when an action was performed via acting-as; `was_acting_as INTEGER DEFAULT 0` is the boolean flag. When not acting-as, `acting_user_id` is NULL and `was_acting_as = 0`. |
| `kiosk_pages` | `workspace_id` | same. |
| `alert_configs` | `workspace_id` | same. |
| `device_fingerprints` | (none) | platform-wide reinstall guard, stays user-keyed by intent. |
### 3.3 Stripe columns
`users.plan_id`, `users.stripe_customer_id`, `users.stripe_subscription_id`, `users.subscription_status`, `users.subscription_ends` -> move to `organizations`. Columns stay on `users` as nullable for one release (see Q9 default), then drop in V2.
### 3.3.1 Workspace billing metadata (add D)
The `workspaces` table also carries reseller-side annotation columns. These are visible and editable only to `org_owner` and `org_admin`. `workspace_admin` and below cannot see them. They never affect Stripe, never affect device caps, and ScreenTinker never emails the addresses stored in them.
```sql
ALTER TABLE workspaces ADD COLUMN billing_type TEXT DEFAULT 'client_billable';
ALTER TABLE workspaces ADD COLUMN billing_notes TEXT;
ALTER TABLE workspaces ADD COLUMN billing_contact_email TEXT;
ALTER TABLE workspaces ADD COLUMN billing_contract_ref TEXT;
```
| Column | Purpose |
| --- | --- |
| `billing_type` | One of `client_billable` (default - workspace is a paying client of the reseller), `client_complimentary` (client the reseller is comping - demo, charity, freebie), `internal` (the reseller's own usage - test bed, sales demo, their own signage). |
| `billing_notes` | Free-text reseller memory of the deal: "Acme - $50/mo, net-30, started 2025-09-01". |
| `billing_contact_email` | Whom at the client the reseller invoices. Stored only; never receives platform email. |
| `billing_contract_ref` | Reseller's internal cross-reference (contract id, CRM ticket, whatever). |
How a reseller actually charges these clients (full retail, discounted, comped, not at all) is the reseller's business and never modeled or enforced by the platform. See §8.1.
### 3.4 What stays user-scoped
- `users` table itself: identity, password, auth_provider, name, avatar.
- `device_fingerprints`: reinstall guard, no tenancy concept.
- `team_invites` / `workspace_invites`: scoped to the inviting workspace.
### 3.5 What gets both org and workspace IDs
Only `activity_log`. Some entries (billing, workspace create/delete) need to live at the org level even if no workspace context applies; others (device pair, content upload) carry both for filtering.
## 4. Migration
### 4.1 Strategy
Every existing user with any owned data becomes an `organizations` row plus a default `workspaces` row plus optional additional workspaces (their existing teams).
```
For each user U with owned data:
org_id = new uuid
insert organizations(id=org_id, name="<U.email>'s organization",
owner_user_id=U.id,
plan_id=U.plan_id,
stripe_*=U.stripe_*,
subscription_*=U.subscription_*)
insert organization_members(org_id, U.id, role='org_owner')
if U owns any teams T1..Tn:
for each Ti:
insert workspaces(id=Ti.id, organization_id=org_id, name=Ti.name, created_by=Ti.owner_id)
-- workspace.id reuses team.id so referencing rows continue to resolve
for each team_members row M of Ti:
ws_role = map(M.role) -- owner -> workspace_admin, editor -> workspace_editor, viewer -> workspace_viewer
insert workspace_members(workspace_id=Ti.id, user_id=M.user_id, role=ws_role)
-- pick a default workspace for U: the team they own with the most data (or first by created_at)
else:
ws_id = new uuid
insert workspaces(id=ws_id, organization_id=org_id, name='Default', created_by=U.id)
insert workspace_members(workspace_id=ws_id, user_id=U.id, role='workspace_admin')
for each user-scoped table (devices, content, etc):
UPDATE table SET workspace_id = (
-- if team_id is set on the row, use it as the workspace_id (team and workspace share id)
-- otherwise use U's default workspace
COALESCE(table.team_id, U_default_ws_id)
)
WHERE user_id = U.id
For each user U with users.role IN ('superadmin'):
UPDATE users SET role='platform_admin' WHERE id=U.id
For each user U with users.role = 'admin':
-- legacy intermediate role is dropped. Their migrated org gets them as org_admin.
-- if they already became org_owner via the loop above, leave as org_owner.
UPDATE users SET role='user' WHERE id=U.id
-- (org_admin row is added by the per-org loop above for any team-membered admins)
```
Re-using `team.id` as the new `workspace.id` is intentional: every existing FK that points at a team continues to resolve without rewriting. Sockets, JWTs, and bookmarked URLs survive.
### 4.2 Migration SQL (high level)
Lives in `server/db/database.js` migrations array, idempotent, runs on next boot:
```sql
-- New tables (4x CREATE TABLE IF NOT EXISTS, shown in 3.1).
-- Additive columns. Each wrapped in try/catch in the migration runner so re-runs are safe.
ALTER TABLE devices ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE content ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE playlists ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE layouts ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE widgets ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE schedules ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE video_walls ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE device_groups ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE white_labels ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE kiosk_pages ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE alert_configs ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE activity_log ADD COLUMN workspace_id TEXT REFERENCES workspaces(id);
ALTER TABLE activity_log ADD COLUMN organization_id TEXT REFERENCES organizations(id);
ALTER TABLE activity_log ADD COLUMN acting_user_id TEXT REFERENCES users(id);
ALTER TABLE activity_log ADD COLUMN was_acting_as INTEGER DEFAULT 0;
-- Reseller-side workspace annotations (add D).
ALTER TABLE workspaces ADD COLUMN billing_type TEXT DEFAULT 'client_billable';
ALTER TABLE workspaces ADD COLUMN billing_notes TEXT;
ALTER TABLE workspaces ADD COLUMN billing_contact_email TEXT;
ALTER TABLE workspaces ADD COLUMN billing_contract_ref TEXT;
-- Indexes for the new lookup paths.
CREATE INDEX IF NOT EXISTS idx_devices_workspace ON devices(workspace_id);
CREATE INDEX IF NOT EXISTS idx_content_workspace ON content(workspace_id);
CREATE INDEX IF NOT EXISTS idx_playlists_workspace ON playlists(workspace_id);
CREATE INDEX IF NOT EXISTS idx_video_walls_workspace ON video_walls(workspace_id);
CREATE INDEX IF NOT EXISTS idx_workspaces_organization ON workspaces(organization_id);
CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id);
CREATE INDEX IF NOT EXISTS idx_organization_members_user ON organization_members(user_id);
```
Backfill runs as a one-shot in a transaction inside the migration runner, behind a `schema_migrations` row keyed `2026-05-11-multitenancy-backfill` so it only runs once. Pseudocode in 4.1; concrete script ships in Phase 1.
### 4.3 Down-migration
We do NOT auto-rollback. On failure during Phase 1 testing:
1. Take a pre-migration backup (the migration runner snapshots the SQLite file to `data/screentinker.pre-multitenancy.sqlite` before applying anything).
2. Manual rollback: `cp data/screentinker.pre-multitenancy.sqlite data/screentinker.sqlite && systemctl restart`.
3. No partial-migration state is allowed: the backfill runs inside `BEGIN TRANSACTION ... COMMIT`. Any error rolls the whole batch.
Phase 1 ships with a `node scripts/rollback-multitenancy.js` that drops the new tables and ALTER columns for completeness. It is NEVER auto-invoked.
### 4.4 Validation gate
Before Phase 2 begins, Phase 1 must produce a passing local test:
- Clone the production SQLite backup to dev.
- Run migrations.
- For every user U, run a diff:
- count(devices WHERE user_id=U) before == count(devices WHERE workspace_id IN ws_of_U) after.
- same for content, playlists, layouts, widgets, schedules, video_walls.
- Existing JWTs still resolve to a valid current_workspace_id.
- Existing API calls still return the same shape (Phase 2 changes the shape; Phase 1 only adds columns).
## 5. API surface
### 5.1 New endpoints
```
POST /api/orgs create org (platform_admin or self-host bootstrap)
GET /api/orgs list orgs the caller can see
GET /api/orgs/:id org detail (incl. workspaces, members, billing summary)
PUT /api/orgs/:id update org (name, branding defaults)
DELETE /api/orgs/:id delete org (org_owner only)
GET /api/orgs/:id/usage rollup: per-workspace device counts (add B)
POST /api/orgs/:id/members invite org member (org_owner)
DELETE /api/orgs/:id/members/:user_id remove org member
POST /api/orgs/:id/workspaces create workspace
GET /api/workspaces list workspaces the caller can access
GET /api/workspaces/:id workspace detail
PUT /api/workspaces/:id update (name, branding override)
DELETE /api/workspaces/:id delete (org_owner)
POST /api/workspaces/:id/members invite member to a workspace
DELETE /api/workspaces/:id/members/:user_id remove member
POST /api/auth/switch-workspace session swap: { workspace_id } -> new JWT
GET /api/auth/me now returns { user, current_workspace, accessible_workspaces[], current_org_role }
```
### 5.2 Existing endpoints
V1 keeps every existing path operational. Scoping happens implicitly:
- JWT carries `current_workspace_id`. Set on login (last-used or first available). Updated on `/api/auth/switch-workspace`.
- Every existing route resolves `workspace_id` from JWT and filters by it instead of `user_id`.
- Optional `?workspace_id=` query param overrides per-request (used by org_owner tooling).
- No 308 redirects in V1. Path-versioned `/api/workspaces/:wid/...` form is deferred to V2.
The result is that frontend code in V1 continues to call `/api/devices`, `/api/content`, etc., unchanged. The middleware does the work.
### 5.3 Auth flow
```
POST /api/auth/login -> { token, user, accessible_workspaces[], current_workspace_id }
```
If `accessible_workspaces.length === 1`, frontend auto-enters it.
If `accessible_workspaces.length > 1`, frontend shows the picker.
If `accessible_workspaces.length === 0`, account is dormant (org but no workspace memberships) -> show "No workspace yet" landing.
## 6. Workspace switching UX
- **Picker** at `#/select-workspace` shown after login when count > 1. Two columns:
- "My workspaces" (workspaces where user is a member).
- "Acting as" (for org_owner / org_admin: every workspace inside their org they aren't a direct member of). Visible only if user is org-level.
- **Persistent header indicator**: workspace name + dropdown arrow at the top-left of the dashboard. Click opens the same picker as a popover.
- **Acting-as ribbon**: when a reseller is inside a workspace they aren't a direct workspace_member of, a yellow bar pinned below the header reads `Acting as workspace: <name>. <Return to my workspace>`. Clicking the link switches back to the user's default workspace.
- **Audit log**: every action recorded in an acting-as session has `acting_user_id = reseller, target_workspace_id = client_workspace, was_acting_as = true`. UI in the audit log filters surfaces these distinctly.
## 7. White-label
- `white_labels.workspace_id` replaces `white_labels.user_id`. Branding belongs to the workspace.
- `organizations.default_*` columns hold the org's default brand. On workspace create, the workspace's `white_labels` row is initialized from these defaults; the workspace_admin can override any field.
- `branding.js` resolution order: per-workspace `white_labels` row -> org defaults -> platform defaults.
- Custom domain per workspace: V2. The `white_labels.custom_domain` column stays unused in V1.
## 8. Billing model (rollup) and lifecycle (add A)
### 8.1 Model
**The org_owner is the sole billable entity.** A workspace under a paid org has:
- NO Stripe customer.
- NO Stripe subscription.
- NO billing portal access.
- NO platform-level billing relationship of any kind.
The platform sees one customer per org: the org_owner. Stripe knows nothing about workspaces.
How a reseller charges their own clients (full price, discounted, complimentary, comped, internal-only) is **entirely the reseller's business**. The platform does not model it, enforce it, or contact the client. The `workspaces.billing_type` / `billing_notes` / `billing_contact_email` / `billing_contract_ref` columns (see §3.3.1) exist purely as the reseller's own memory and are never read by any platform code path that touches money or email.
- One Stripe subscription per **organization**, attached to `org_owner`.
- `plans.max_devices` is the org-wide cap. Sum of devices across all workspaces of the org is checked.
- Workspaces inside a paid org have no individual plan or Stripe relationship (see above).
- Self-hosted: Stripe enforcement off regardless.
### 8.2 Device-count enforcement at pairing time
```
on POST /api/provision/pair:
org = orgOf(caller)
total_devices = sum(devices WHERE workspace_id IN workspaces_of(org.id))
plan = plan_of(org)
if total_devices >= plan.max_devices and plan.id != 'enterprise':
return 402 { error: 'Org device limit reached', current: total_devices, limit: plan.max_devices }
...
```
`device_status_log` shows the user a clear error: which org, which limit, which plan.
### 8.3 Subscription lifecycle (add A)
States on the `organizations` row: `active`, `past_due`, `grace`, `read_only`, `locked`. Driven by the existing Stripe webhook plus a daily cron.
Transitions:
| Event | Action |
| --- | --- |
| `invoice.payment_failed` | set `subscription_status = 'past_due'`, set `grace_period_ends = now + 7d`. Send email to org_owner + org_admins. |
| `invoice.payment_succeeded` while past_due | clear `grace_period_ends`, set `subscription_status = 'active'`. |
| daily cron, state == `past_due` AND `grace_period_ends < now` | enter `read_only`. **Reset `grace_period_ends = now + 30d`** so the read_only -> locked transition has a fresh 30-day clock and does not fire on the very next cron run. Send email. |
| `customer.subscription.deleted` (explicit cancel) | move to `read_only` immediately; set `grace_period_ends = now + 30d`. |
| daily cron, state == `read_only` AND `grace_period_ends < now` | move to `locked`. Set `locked_at = now`. |
| `checkout.session.completed` while in any non-active state | clear `grace_period_ends` and `locked_at`, set `active`. |
Behavior per state:
| State | Devices play content | Dashboard read | Dashboard write | New device pairing | Stripe portal |
| --- | --- | --- | --- | --- | --- |
| `active` | yes | yes | yes | yes | yes |
| `past_due` | yes | yes | yes | yes | yes (banner: "payment failed, update card by <date>") |
| `read_only` | **yes** (devices keep playing what they already have) | yes | **no** (locked banner, all write routes return 423) | no | yes |
| `locked` | **no** (devices receive empty playlist, fall back to a "subscription expired" splash card with org-owner email) | yes (so org_owner can see what they have) | no | no | yes |
Why this shape:
- Resellers can't tolerate "we missed a payment and 80 displays went black at 2am." Devices keep playing in `read_only`.
- 7-day grace covers most payment-method-update lag.
- 30-day grace on explicit cancel matches stripe-customer-portal cancel-at-period-end semantics.
- `locked` is the only state where devices visibly degrade. By then we've sent 4+ notifications across ~37 days.
Recovery from any state by paying invoice or re-subscribing is automatic via webhook.
#### Player and write-path mechanism in `read_only`
The `read_only` state is implemented by two surgical changes, neither of which touches what's already on the displays:
1. **Existing playlist delivery keeps working.** The device sync path (`buildPlaylistPayload`, the `device:playlist-update` socket emission, and `GET /api/provision/sync`) ignore org subscription state entirely. They read whatever is already assigned to the device's workspace and return it as today. Devices keep receiving the same content, schedules, layouts, and playlists they had at the moment the org entered `read_only`. Reconnects, screenshot push, telemetry heartbeat: all unchanged.
2. **Write routes are blocked at the middleware level.** A new `requireWritableOrg` middleware runs on every mutating route (POST/PUT/PATCH/DELETE that creates or edits workspace-scoped resources). It looks up the caller's org subscription state. If state is `read_only` or `locked`, it returns `423 Locked` with a body explaining which org and how to recover (link to Stripe portal). GET routes are unaffected.
Blocked routes in `read_only` (non-exhaustive):
`/api/devices` (POST/PUT/DELETE), `/api/provision/pair`, `/api/content` (upload, edit, delete, folder ops), `/api/playlists` (create/update/publish/items), `/api/schedules` (any write), `/api/layouts` (write), `/api/widgets` (write), `/api/video-walls` (any write), `/api/device-groups` (any write), `/api/teams`/`/api/workspaces` member changes other than the org_owner removing themselves.
Routes that stay open in `read_only`:
all GETs, Stripe billing portal/checkout (so the customer can pay and recover), `/api/auth/*` (login, switch-workspace, logout), `/api/orgs/:id/usage` (visibility), `/api/activity` (visibility), platform_admin endpoints.
In `locked`, the same write-routes stay blocked AND `buildPlaylistPayload` returns `{ assignments: [], suspended: true, message: 'Subscription expired', detail: '<org_owner email>' }`. The existing "suspended" branch in the web player already renders this splash; we just wire it to org state.
#### Uniform application to every workspace (add D)
When an org enters `read_only` or `locked`, **all of its workspaces are affected identically, regardless of `billing_type`**. There is no special protection for `internal` or `client_complimentary` workspaces. The reseller's payment problem affects every workspace under them. This is intentional: the platform has exactly one billable customer (the org_owner), and managing client expectations during a payment lapse is the reseller's responsibility, not the platform's.
### 8.4 Free tier
Free tier = `plans.id = 'free'`, `max_devices = 1`. Behaves identically to a paid plan that happens to have a low cap. Trial-expiry behavior in `deviceSocket.js` already exists and stays; it now keys off org state instead of user state.
## 9. Per-workspace usage rollup (add B)
Read-only visibility, no enforcement.
`GET /api/orgs/:id/usage` returns:
```json
{
"organization_id": "org_abc",
"plan_id": "pro",
"max_devices": 100,
"total_devices": 95,
"subscription_status": "active",
"workspaces": [
{ "workspace_id": "ws_acme", "name": "AcmeClient", "device_count": 80, "online": 78, "offline": 2, "billing_type": "client_billable" },
{ "workspace_id": "ws_foo", "name": "FooClient", "device_count": 15, "online": 15, "offline": 0, "billing_type": "client_complimentary" },
{ "workspace_id": "ws_demo", "name": "Sales Demo", "device_count": 2, "online": 2, "offline": 0, "billing_type": "internal" }
]
}
```
`billing_type` is included so the reseller can see their mix at a glance (paying clients vs comped vs internal use) without opening each workspace. The org_owner UI may use it for a stacked summary (e.g. "92 client_billable, 15 client_complimentary, 2 internal of 100 cap").
UI: in the org_owner / org_admin org-settings view, a stacked horizontal bar shows each workspace's slice of the org's cap, plus a row table with raw counts. Click a workspace name to switch into it (acting-as). No allocation UI - resellers eyeball the bar and add devices wherever they want.
`workspace_admin` and below cannot call this endpoint (their `org_id` doesn't resolve, returns 403).
## 10. Device pairing while acting-as (add C)
Pairing flow is workspace-scoped: a paired device's `workspace_id` is whatever workspace the user is currently in at the moment of confirmation.
### 10.1 Reseller acting inside a client workspace
1. The acting-as ribbon is showing (`Acting as workspace: Acme`).
2. Reseller clicks "Add display" on the dashboard.
3. The "Pair Display" modal opens. Top of modal:
```
New display will be added to: Acme (you are acting as this workspace)
```
with a button `Change target workspace` that opens a workspace dropdown limited to workspaces of the current org (resellers cannot pair a device into a workspace outside their org).
4. Reseller enters pairing code, clicks "Pair".
5. Device row is inserted with:
- `workspace_id = ws_acme` (the acting-as workspace, or the target from step 3 if changed)
- `user_id = reseller.id` (created_by record)
- `team_id = ws_acme` (legacy column for compatibility shim)
6. Org-wide device count enforcement runs (section 8.2). If over cap, return 402 BEFORE inserting the row.
7. Activity log: `acting_user_id = reseller, workspace_id = ws_acme, action = 'device.paired', was_acting_as = true`.
### 10.2 Reseller NOT acting-as (in their own context)
Two sub-cases. We pick one for V1.
**V1 default: force a workspace pick at pairing time.**
When `org_owner` / `org_admin` is in their org-level context (no specific workspace selected, e.g. on the org settings page), the "Add display" CTA is disabled with a tooltip `Enter a workspace first to pair a device`. They cannot pair from the org settings page.
When they are in their personal default workspace (which is just one of the org's workspaces), pairing works as in 10.1 with that workspace as the target.
Why force the pick rather than land in personal default:
- Resellers consistently report: "I paired five devices into the wrong workspace because I forgot to switch first." Forcing the explicit choice prevents this footgun.
- Personal-default workspace concept is fragile for resellers who have no personal use case (they only manage clients).
**Alternative (rejected for V1):** Allow pairing from org-level context and require a workspace selector inside the pairing modal. Adds an extra step for every single-workspace customer (the majority of self-hosted users). Reconsidered if real-world feedback contradicts.
### 10.3 Workspace_admin / editor / viewer
Pairing target is always the workspace they're in. No selector shown. Their session has exactly one workspace; the modal just says `New display will be added to: <workspace name>`.
## 11. Self-hosted bootstrap
On a fresh self-hosted install (`SELF_HOSTED=true`, empty database):
1. First registrant becomes `users.role = 'platform_admin'`.
2. Same registrant becomes the `org_owner` of an auto-created organization named `<name>'s organization`.
3. Same registrant becomes `workspace_admin` of an auto-created workspace named `Default`.
4. `plans.id = 'enterprise'` is force-assigned to the org with `max_devices = 999999`. No Stripe lookup.
Subsequent registrants when `DISABLE_REGISTRATION=false`:
- Lands as `users.role = 'user'`, no org or workspace memberships.
- The platform_admin must invite them to a workspace (or grant org_admin).
- Frontend shows "No workspace yet. Ask your administrator for access."
When `DISABLE_REGISTRATION=true`: registration is closed at the route level. Bootstrap user is the only auto-created identity; others must arrive via invite.
Self-hosted instances may create multiple organizations. The `platform_admin` UI exposes a "create new organization" button. No Stripe involvement.
## 12. Socket.IO scoping
- **Device sockets** (`/device`): unchanged. They join the `device_id` room as today.
- **Dashboard sockets** (`/dashboard`): join `ws:<current_workspace_id>` instead of an implicit per-user room.
- When the user switches workspace, the socket leaves the old room and joins the new one. Frontend emits `dashboard:switch-workspace` with the new id; server validates membership/acting-as and updates rooms.
- Server emits `dashboard:device-status`, `dashboard:screenshot-ready`, `dashboard:playback-progress`, `dashboard:wall-changed` to `ws:<workspace_id>` of the affected resource, not globally.
- The existing audience filter (every dashboard reloads after `dashboard:wall-changed` and re-fetches via the access-controlled GET) means even if a stray broadcast reaches a wrong workspace, the GET would 403; for V1 we tighten the broadcast at emit time anyway.
## 13. Phase-by-phase rollout
### Phase 0 - design (THIS DOC). Done on approval.
### Phase 1 - database and migration
- Add the four new tables.
- Add `workspace_id` / `organization_id` columns on existing tables.
- Backfill: every existing user becomes an org + workspace(s) per section 4.
- Snapshot pre-migration DB before any ALTER.
- Validation script: row-count parity per user before vs after.
- No route changes yet. Frontend unchanged. Existing logins still work because middleware reads `team_id` as before in V0 paths.
- Gate: visual test - log in as three different existing users, see exactly the same dashboard as before migration.
### Phase 2 - backend permissions and scoping
- Org and workspace models in `server/models/` (or wherever the repo wants them).
- Auth middleware resolves `current_workspace_id`. JWT gets `current_workspace_id`. `/api/auth/me` returns memberships.
- `/api/auth/switch-workspace` endpoint.
- Permission helpers (`can()` per section 2.5).
- Every existing route: replace `user_id` filter with `workspace_id` filter. Keep `user_id` writes as created_by.
- Socket.IO room scoping (section 12).
- Gate: regression test of every route under the new scoping. Existing client unchanged, all functionality works.
### Phase 3 - frontend
- Workspace picker view at `#/select-workspace`.
- Header workspace indicator + dropdown.
- Acting-as ribbon.
- Org settings page with: members, workspaces list, branding defaults, usage rollup (add B). Rollup table includes a `billing_type` column.
- Workspace settings page: members, branding override, delete-workspace (org_owner only).
- Workspace settings "Billing (reseller use)" section (add D), visible only to `org_owner` and `org_admin`:
- `billing_type` dropdown (client_billable / client_complimentary / internal)
- `billing_notes` textarea
- `billing_contact_email` field
- `billing_contract_ref` field
- Help text: "This information is for your own records. ScreenTinker does not bill or contact clients - that is between you and them."
- The whole section is gated server-side and hidden client-side from `workspace_admin` and below.
- Updated pairing modal per section 10 (target workspace banner / selector).
### Phase 4 - billing
- Move Stripe customer/subscription writes to the org row.
- Device-count enforcement at pair time queries the org rollup.
- Webhook handlers update the org's lifecycle state machine (section 8.3).
- `read_only` and `locked` banners on dashboard chrome.
- Daily cron job for grace-period expiry transitions.
### Phase 5 - self-hosted validation
- Fresh `SELF_HOSTED=true` install on a clean SQLite DB.
- First registrant becomes platform_admin + org_owner + workspace_admin.
- `DISABLE_REGISTRATION=true` still works.
- Multi-org creation works (platform_admin can spin up multiple orgs for separate resellers).
- Stripe routes return `{ enabled: false }` and the billing UI hides.
## 14. Decisions deferred to V2
- Subdomain-per-workspace (`client.screentinker.com`) and per-workspace custom domain via CNAME. Requires nginx automation + cert lifecycle (likely a sidecar like caddy or acme.sh integration).
- Per-workspace device-count caps (allocation). V1 shows the rollup view (add B); allocation UI follows.
- **Per-client invoicing reports (add D)**: per-workspace soft caps combined with `billing_type` metadata enables a future "invoicing CSV" - V2 could render, for each `client_billable` workspace, a device-month consumption summary the reseller can import into their own invoicing system. Purely a reseller convenience; no money flows through ScreenTinker. Flagged here, deferred.
- Path-versioned `/api/workspaces/:wid/...` form with 308 redirects from legacy paths.
- Drop the now-unused `users.plan_id`, `users.stripe_*`, `users.subscription_*` columns. Stay nullable in V1, drop in V2.
- Drop the `team_id` compatibility column on resource tables.
- Nested teams inside a workspace. Not asked for. Don't add without a concrete request.
- "Transfer workspace between organizations" - rare; defer until requested.
## 15. Open questions still on the table
None blocking Phase 1. The following are nice-to-have clarifications you can answer at any time before Phase 3:
- **Default workspace name format**: current proposal is `Default`. Resellers might prefer `<client name>` only with no `Default` workspace at all. We can confirm during Phase 3 when the workspace-create UX lands.
- **Email notifications for invites**: today's team invite email template gets reused for both org-member and workspace-member invites with subject lines that distinguish them. Confirm copy in Phase 3.
- **Activity log retention**: currently unlimited. With orgs, do we want a per-org retention cap (90 days default, configurable on enterprise)? Defer to V2.
End of design doc.

1728
docs/openapi.yaml Normal file

File diff suppressed because it is too large Load diff

17
frontend/api-docs.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>ScreenTinker API Reference</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="description" content="ScreenTinker public API reference"/>
<style>body { margin: 0; padding: 0; }</style>
</head>
<body>
<!-- Self-hosted Redoc: the spec is served at /openapi.yaml and the Redoc bundle is
vendored locally (no CDN) so the docs work on an offline/air-gapped instance.
The <redoc> element auto-initialises from the standalone bundle. -->
<redoc spec-url="/openapi.yaml"></redoc>
<script src="/vendor/redoc.standalone.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View file

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Best OptiSigns Alternative (2026) - Free & Open Source | ScreenTinker</title>
<meta name="description" content="Looking for an OptiSigns alternative? ScreenTinker is open source, MIT licensed, self-hostable, and costs less at scale. Honest feature and pricing comparison.">
<meta name="keywords" content="optisigns alternative, free optisigns alternative, open source digital signage, self hosted digital signage, digital signage cms">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://screentinker.com/compare/optisigns-alternative.html">
<meta property="og:type" content="article">
<meta property="og:url" content="https://screentinker.com/compare/optisigns-alternative.html">
<meta property="og:title" content="Best OptiSigns Alternative (2026) | ScreenTinker">
<meta property="og:description" content="ScreenTinker vs OptiSigns. Open source, self-hostable, lower cost at scale.">
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta property="og:site_name" content="ScreenTinker">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Best OptiSigns Alternative (2026)">
<meta name="twitter:description" content="ScreenTinker vs OptiSigns. Open source, self-hostable, lower cost at scale.">
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta name="theme-color" content="#111827">
<link rel="icon" href="/assets/icon-192.png">
<link rel="apple-touch-icon" href="/assets/icon-192.png">
<link rel="stylesheet" href="/css/seo-page.css">
</head>
<body>
<nav>
<div class="nav-inner">
<div class="nav-logo">
<a href="/" style="display:flex;align-items:center;gap:10px">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<span class="nav-logo-text">ScreenTinker</span>
</a>
</div>
<div class="nav-links">
<a href="/#features">Features</a>
<a href="/#pricing">Pricing</a>
<a href="/#compare">Compare</a>
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
</div>
</div>
</nav>
<main class="article">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Home</a>
<span>/</span>
<a href="/#compare">Compare</a>
<span>/</span>
<span>OptiSigns Alternative</span>
</nav>
<h1>Best OptiSigns Alternative (2026): ScreenTinker vs OptiSigns</h1>
<p class="lead">OptiSigns has built a strong reputation in restaurants, retail, and small business signage. Here is an honest comparison with ScreenTinker covering features, pricing, and where each fits best.</p>
<h2>The short answer</h2>
<p><strong>OptiSigns</strong> is a well-marketed cloud signage product with a deep template library and good documentation. It targets non-technical buyers and works particularly well for restaurants and retail menus.</p>
<p><strong>ScreenTinker</strong> is open source (MIT licensed), self-hostable, runs on hardware you already own with no lock-in, and is meaningfully cheaper at higher screen counts. It is a better fit if you have any technical capacity, you care about data sovereignty, or you operate at a scale where per-screen pricing hurts.</p>
<h2>Quick comparison</h2>
<div class="compare-table-wrap">
<table class="compare-table">
<thead>
<tr><th>Feature</th><th>ScreenTinker</th><th>OptiSigns</th></tr>
</thead>
<tbody>
<tr><td>Open source</td><td class="yes">Yes (MIT)</td><td class="no">No</td></tr>
<tr><td>Self-host option</td><td class="yes">Yes</td><td class="no">No (cloud only)</td></tr>
<tr><td>Free plan</td><td class="yes">1 device, 500MB</td><td class="yes">Free, up to 3 screens</td></tr>
<tr><td>Android TV / Fire TV</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>Raspberry Pi</td><td class="yes">Free setup script</td><td class="partial">Limited support<!-- VERIFY: OptiSigns markets 10+ platforms incl. Pi; exact Pi support level not confirmed --></td></tr>
<tr><td>Windows / ChromeOS</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>Web browser player</td><td class="yes">Yes</td><td class="partial">Limited</td></tr>
<tr><td>Video walls</td><td class="yes">Yes (with sync)</td><td class="partial">Paid add-on</td></tr>
<tr><td>Multi-zone layouts</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>Template library</td><td class="partial">Custom designer</td><td class="yes">Large library</td></tr>
<tr><td>Live remote control*</td><td class="yes">Yes</td><td class="partial">Limited</td></tr>
<tr><td>White-label / reseller</td><td class="yes">Yes</td><td class="paid">Paid tier</td></tr>
<tr><td>Pricing for 15 devices</td><td>$99/mo Pro</td><td>~$165/mo (11 USD/screen)</td></tr>
<tr><td>Self-host cost</td><td>Free (your server)</td><td>Not available</td></tr>
</tbody>
</table>
</div>
<p style="font-size:12px;color:var(--dim);line-height:1.6;margin-top:12px">
* Live remote control is Android only and requires granting the on-device accessibility permission.<br>
Comparison as of June 2026, based on each vendor's publicly listed pricing and documentation. Spot an error? <a href="https://github.com/screentinker/screentinker/issues" target="_blank" rel="noopener">Open an issue on GitHub</a> and we'll fix it.
</p>
<h2>Where OptiSigns does well</h2>
<ul>
<li><strong>Templates.</strong> Hundreds of pre-built templates for menus, real estate listings, gym schedules, and more. Best-in-class for non-designers who need to ship fast.</li>
<li><strong>Niche features.</strong> POS integrations for restaurants, MLS feeds for real estate, fitness class schedule integrations.</li>
<li><strong>Documentation and support.</strong> Extensive tutorial library, responsive support team.</li>
</ul>
<h2>Where ScreenTinker is the better choice</h2>
<ul>
<li><strong>Cost at scale.</strong> OptiSigns is around $11/screen/month on the Pro plan. At 15 devices that is $165/mo; ScreenTinker Pro is $99/mo. The gap widens as you add screens.</li>
<li><strong>Self-hosting.</strong> If you cannot or will not put your signage data in a third-party cloud, ScreenTinker is one of the few real options. OptiSigns does not offer this.</li>
<li><strong>Source access.</strong> MIT licensed on <a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>. Read the code, modify it, fork it.</li>
<li><strong>Live remote control.</strong> Stream a live view of any display and inject taps or key events. Most cloud signage tools only show occasional screenshots.</li>
<li><strong>Runs on hardware you already own.</strong> Native Android APK, web player works on any browser, Pi setup script, Windows-friendly, macOS-friendly - no proprietary player to buy.</li>
</ul>
<h2>Pricing example: 25 devices for one year</h2>
<ul>
<li><strong>OptiSigns Pro:</strong> ~$3,300/year (25 x $11/mo)</li>
<li><strong>ScreenTinker:</strong> Custom Enterprise plan or self-host at server cost only</li>
</ul>
<div class="related">
<h2>Related guides</h2>
<ul>
<li><a href="/guides/raspberry-pi-digital-signage.html">How to set up digital signage on a Raspberry Pi</a></li>
<li><a href="/guides/digital-signage-android-tv.html">Free digital signage for Android TV and Fire TV</a></li>
<li><a href="/guides/self-hosted-digital-signage.html">Self-hosted digital signage: complete guide</a></li>
<li><a href="/compare/yodeck-alternative.html">Compare: ScreenTinker vs Yodeck</a></li>
<li><a href="/compare/screencloud-alternative.html">Compare: ScreenTinker vs ScreenCloud</a></li>
</ul>
</div>
<div class="cta">
<h2>Try ScreenTinker free</h2>
<p>Start a 14-day Pro trial. No credit card required.</p>
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free Trial</a>
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">View on GitHub</a>
</div>
</main>
<footer>
<div style="color:var(--dim);font-size:13px">&copy; 2026 ScreenTinker. All rights reserved.</div>
<div class="links">
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
<a href="/legal/terms.html">Terms</a>
<a href="/legal/privacy.html">Privacy</a>
<a href="/legal/third-party.html">Licenses</a>
<a href="/app#/login">Sign In</a>
</div>
</footer>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
{ "@type": "ListItem", "position": 2, "name": "Compare", "item": "https://screentinker.com/#compare" },
{ "@type": "ListItem", "position": 3, "name": "OptiSigns Alternative", "item": "https://screentinker.com/compare/optisigns-alternative.html" }
]
}
</script>
</body>
</html>

View file

@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Best ScreenCloud Alternative (2026) - Free & Open Source | ScreenTinker</title>
<meta name="description" content="ScreenCloud is great but expensive at scale. ScreenTinker is open source, MIT licensed, self-hostable, and a fraction of the price for the same screen count. Compare features, pricing, and platform support.">
<meta name="keywords" content="screencloud alternative, free screencloud alternative, open source digital signage, self hosted digital signage, digital signage cms">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://screentinker.com/compare/screencloud-alternative.html">
<meta property="og:type" content="article">
<meta property="og:url" content="https://screentinker.com/compare/screencloud-alternative.html">
<meta property="og:title" content="Best ScreenCloud Alternative (2026) | ScreenTinker">
<meta property="og:description" content="ScreenTinker vs ScreenCloud. Open source, self-hostable, dramatically cheaper at scale.">
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta property="og:site_name" content="ScreenTinker">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Best ScreenCloud Alternative (2026)">
<meta name="twitter:description" content="ScreenTinker vs ScreenCloud. Open source, self-hostable, dramatically cheaper at scale.">
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta name="theme-color" content="#111827">
<link rel="icon" href="/assets/icon-192.png">
<link rel="apple-touch-icon" href="/assets/icon-192.png">
<link rel="stylesheet" href="/css/seo-page.css">
</head>
<body>
<nav>
<div class="nav-inner">
<div class="nav-logo">
<a href="/" style="display:flex;align-items:center;gap:10px">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<span class="nav-logo-text">ScreenTinker</span>
</a>
</div>
<div class="nav-links">
<a href="/#features">Features</a>
<a href="/#pricing">Pricing</a>
<a href="/#compare">Compare</a>
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
</div>
</div>
</nav>
<main class="article">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Home</a>
<span>/</span>
<a href="/#compare">Compare</a>
<span>/</span>
<span>ScreenCloud Alternative</span>
</nav>
<h1>Best ScreenCloud Alternative (2026): ScreenTinker vs ScreenCloud</h1>
<p class="lead">ScreenCloud is a polished enterprise digital signage platform - but pricing scales fast. Here is an honest comparison covering features, pricing, and where each fits best.</p>
<h2>The short answer</h2>
<p><strong>ScreenCloud</strong> is a mature, well-designed cloud signage product targeted at mid-market and enterprise customers. It has strong app integrations (Slack, Power BI, Google Drive) and excellent support. It is also one of the most expensive options on the market.</p>
<p><strong>ScreenTinker</strong> is open source (MIT licensed), self-hostable, and dramatically cheaper at scale. It is a better fit if you want to keep data on your own infrastructure, you have budget pressure, or your screen count makes ScreenCloud's per-screen pricing untenable.</p>
<h2>Quick comparison</h2>
<div class="compare-table-wrap">
<table class="compare-table">
<thead>
<tr><th>Feature</th><th>ScreenTinker</th><th>ScreenCloud</th></tr>
</thead>
<tbody>
<tr><td>Open source</td><td class="yes">Yes (MIT)</td><td class="no">No</td></tr>
<tr><td>Self-host option</td><td class="yes">Yes</td><td class="no">No (cloud only)</td></tr>
<tr><td>Free plan</td><td class="yes">1 device, 500MB</td><td class="no">14-day trial only</td></tr>
<tr><td>Android TV / Fire TV</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>Raspberry Pi</td><td class="yes">Free setup script</td><td class="yes">ScreenCloud OS</td></tr>
<tr><td>Web browser player</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>Video walls</td><td class="yes">Yes (with sync)</td><td class="yes">Yes</td></tr>
<tr><td>Multi-zone layouts</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>App integrations</td><td class="partial">Custom widgets</td><td class="yes">Built-in (Slack, Power BI, etc.)</td></tr>
<tr><td>Live remote control*</td><td class="yes">Yes</td><td class="partial">Limited</td></tr>
<tr><td>White-label / reseller</td><td class="yes">Yes</td><td class="partial">Enterprise only</td></tr>
<tr><td>Pricing for 5 devices</td><td>$39/mo Starter</td><td>~$108/mo</td></tr>
<tr><td>Pricing for 15 devices</td><td>$99/mo Pro</td><td>~$300+/mo</td></tr>
<tr><td>Self-host cost</td><td>Free (your server)</td><td>Not available</td></tr>
</tbody>
</table>
</div>
<p style="font-size:12px;color:var(--dim);line-height:1.6;margin-top:12px">
* Live remote control is Android only and requires granting the on-device accessibility permission.<br>
Comparison as of June 2026, based on each vendor's publicly listed pricing and documentation. Spot an error? <a href="https://github.com/screentinker/screentinker/issues" target="_blank" rel="noopener">Open an issue on GitHub</a> and we'll fix it.
</p>
<h2>Where ScreenCloud does well</h2>
<ul>
<li><strong>Native app integrations.</strong> Slack channels, Power BI dashboards, Google Drive, OneDrive, and dozens of others ship as built-in apps. If your displays show live business dashboards, this matters.</li>
<li><strong>Enterprise polish.</strong> SOC 2 audited, dedicated account management, mature SAML/SSO support.</li>
<li><strong>Studio (their content designer).</strong> Best-in-class WYSIWYG editor for non-designers.</li>
</ul>
<h2>Where ScreenTinker is the better choice</h2>
<ul>
<li><strong>Cost.</strong> At 15 screens ScreenCloud runs roughly $300/mo and up depending on plan; ScreenTinker Pro is $99/mo. Over a year that is more than $2,000 in savings on a single deployment.</li>
<li><strong>Self-hosting.</strong> ScreenCloud is cloud-only with no on-prem path. If your security team or compliance posture won't allow a third-party cloud, ScreenTinker is one of the few real options.</li>
<li><strong>Source access.</strong> MIT licensed on <a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>. Audit, extend, fork - all permitted.</li>
<li><strong>No hardware lock-in.</strong> ScreenCloud sells "ScreenCloud OS" hardware; ScreenTinker runs on whatever you have - Pi, Android TV, Fire Stick, kiosk PC, browser.</li>
<li><strong>Live remote control.</strong> Stream a live view of any display and inject taps or key events from the dashboard. Useful for remote troubleshooting without a site visit.</li>
</ul>
<h2>Pricing example: 15 devices over 12 months</h2>
<ul>
<li><strong>ScreenCloud (Pro plan):</strong> ~$3,600/year</li>
<li><strong>ScreenTinker (Pro plan):</strong> $1,188/year</li>
<li><strong>ScreenTinker (self-hosted):</strong> Server cost only, typically $5-50/month for a small VPS</li>
</ul>
<div class="related">
<h2>Related guides</h2>
<ul>
<li><a href="/guides/self-hosted-digital-signage.html">Self-hosted digital signage: complete guide</a></li>
<li><a href="/guides/raspberry-pi-digital-signage.html">How to set up digital signage on a Raspberry Pi</a></li>
<li><a href="/guides/digital-signage-android-tv.html">Free digital signage for Android TV and Fire TV</a></li>
<li><a href="/compare/yodeck-alternative.html">Compare: ScreenTinker vs Yodeck</a></li>
<li><a href="/compare/optisigns-alternative.html">Compare: ScreenTinker vs OptiSigns</a></li>
</ul>
</div>
<div class="cta">
<h2>Try ScreenTinker free</h2>
<p>Start a 14-day Pro trial. No credit card required.</p>
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free Trial</a>
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">View on GitHub</a>
</div>
</main>
<footer>
<div style="color:var(--dim);font-size:13px">&copy; 2026 ScreenTinker. All rights reserved.</div>
<div class="links">
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
<a href="/legal/terms.html">Terms</a>
<a href="/legal/privacy.html">Privacy</a>
<a href="/legal/third-party.html">Licenses</a>
<a href="/app#/login">Sign In</a>
</div>
</footer>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
{ "@type": "ListItem", "position": 2, "name": "Compare", "item": "https://screentinker.com/#compare" },
{ "@type": "ListItem", "position": 3, "name": "ScreenCloud Alternative", "item": "https://screentinker.com/compare/screencloud-alternative.html" }
]
}
</script>
</body>
</html>

View file

@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Best Yodeck Alternative (2026) - Free & Open Source | ScreenTinker</title>
<meta name="description" content="Looking for a Yodeck alternative? ScreenTinker is open source, MIT licensed, self-hostable, and runs on any screen with no hardware lock-in. Free plan included. Compare features, pricing, and platform support.">
<meta name="keywords" content="yodeck alternative, free yodeck alternative, open source digital signage, self hosted digital signage, digital signage cms">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://screentinker.com/compare/yodeck-alternative.html">
<meta property="og:type" content="article">
<meta property="og:url" content="https://screentinker.com/compare/yodeck-alternative.html">
<meta property="og:title" content="Best Yodeck Alternative (2026) - Free & Open Source | ScreenTinker">
<meta property="og:description" content="ScreenTinker vs Yodeck. Open source, self-hostable, no hardware lock-in. Free plan included.">
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta property="og:site_name" content="ScreenTinker">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Best Yodeck Alternative (2026) - Free & Open Source">
<meta name="twitter:description" content="ScreenTinker vs Yodeck. Open source, self-hostable, no hardware lock-in. Free plan included.">
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta name="theme-color" content="#111827">
<link rel="icon" href="/assets/icon-192.png">
<link rel="apple-touch-icon" href="/assets/icon-192.png">
<link rel="stylesheet" href="/css/seo-page.css">
</head>
<body>
<nav>
<div class="nav-inner">
<div class="nav-logo">
<a href="/" style="display:flex;align-items:center;gap:10px">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<span class="nav-logo-text">ScreenTinker</span>
</a>
</div>
<div class="nav-links">
<a href="/#features">Features</a>
<a href="/#pricing">Pricing</a>
<a href="/#compare">Compare</a>
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
</div>
</div>
</nav>
<main class="article">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Home</a>
<span>/</span>
<a href="/#compare">Compare</a>
<span>/</span>
<span>Yodeck Alternative</span>
</nav>
<h1>Best Yodeck Alternative (2026): ScreenTinker vs Yodeck</h1>
<p class="lead">Looking for an open-source, self-hostable alternative to Yodeck? Here is an honest comparison covering pricing, features, platform support, and where each tool fits best.</p>
<h2>The short answer</h2>
<p><strong>Yodeck</strong> is a polished, easy-to-use cloud digital signage product with a Pi player included on paid plans. It is a great fit if you want to plug in and go and you are happy with cloud-only hosting and per-screen pricing.</p>
<p><strong>ScreenTinker</strong> is open source (MIT licensed), self-hostable, and runs on any screen you already own with no hardware lock-in. It is a better fit if you want to keep your data on your own infrastructure, avoid per-screen lock-in, or you have more than a handful of screens and want to control the cost curve.</p>
<h2>Quick comparison</h2>
<div class="compare-table-wrap">
<table class="compare-table">
<thead>
<tr><th>Feature</th><th>ScreenTinker</th><th>Yodeck</th></tr>
</thead>
<tbody>
<tr><td>Open source</td><td class="yes">Yes (MIT)</td><td class="no">No</td></tr>
<tr><td>Self-host option</td><td class="yes">Yes</td><td class="no">No (cloud only)</td></tr>
<tr><td>Free plan</td><td class="yes">1 device, 500MB</td><td class="yes">1 device</td></tr>
<tr><td>Android TV / Fire TV</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>Raspberry Pi</td><td class="yes">Free setup script</td><td class="yes">Player included on paid plans</td></tr>
<tr><td>Windows / ChromeOS</td><td class="yes">Yes (web player)</td><td class="yes">Yes</td></tr>
<tr><td>Web browser player</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>Video walls (multi-screen sync)</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>Multi-zone layouts</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>Live remote control*</td><td class="yes">Yes</td><td class="partial">Screenshot only</td></tr>
<tr><td>Kiosk / interactive mode</td><td class="yes">Yes</td><td class="yes">Yes</td></tr>
<tr><td>White-label / reseller</td><td class="yes">Yes</td><td class="partial">Enterprise tier</td></tr>
<tr><td>Pricing for 15 devices</td><td>$99/mo Pro</td><td>~$120/mo (8 USD/screen)</td></tr>
<tr><td>Self-host cost</td><td>Free (your server)</td><td>Not available</td></tr>
</tbody>
</table>
</div>
<p style="font-size:12px;color:var(--dim);line-height:1.6;margin-top:12px">
* Live remote control is Android only and requires granting the on-device accessibility permission.<br>
Comparison as of June 2026, based on each vendor's publicly listed pricing and documentation. Spot an error? <a href="https://github.com/screentinker/screentinker/issues" target="_blank" rel="noopener">Open an issue on GitHub</a> and we'll fix it.
</p>
<h2>Where Yodeck does well</h2>
<ul>
<li><strong>Onboarding.</strong> Yodeck ships pre-configured Pi players on paid plans, which removes a real setup step for non-technical buyers.</li>
<li><strong>Polish.</strong> The product has been around since 2014, and the cloud experience is mature.</li>
<li><strong>Templates.</strong> A large pre-built template library for menus, lobby boards, and announcements.</li>
</ul>
<h2>Where ScreenTinker is the better choice</h2>
<ul>
<li><strong>You need data sovereignty.</strong> If your content includes PII, internal documents, or you operate in regulated industries (healthcare, government, finance), self-hosting is the only way to keep data off a third-party cloud. Yodeck cannot do this.</li>
<li><strong>You have more than a handful of screens.</strong> Per-screen pricing scales linearly. ScreenTinker Pro is flat at $99/mo for 15 devices, and self-hosters pay nothing per device. At 50+ screens the total cost difference is significant.</li>
<li><strong>You want platform flexibility.</strong> ScreenTinker runs on any device with a browser - Smart TVs, ChromeOS, kiosk PCs, even old Macs. You are not locked into a specific Pi SKU.</li>
<li><strong>You want to read or modify the source.</strong> ScreenTinker is MIT licensed on <a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>. Audit the code, extend it, or fork it.</li>
<li><strong>You want live remote control.</strong> ScreenTinker streams a live screenshot feed and forwards touches and key events back to the device. Yodeck only takes occasional screenshots.</li>
</ul>
<h2>Pricing snapshot</h2>
<p>Yodeck charges per screen per month, typically <strong>$8/screen/mo</strong> on the standard plan with annual billing. ScreenTinker Pro is a flat <strong>$99/mo for 15 devices</strong>. Crossover happens around 12-13 screens; above that ScreenTinker is meaningfully cheaper. Self-hosters pay nothing per device.</p>
<div class="related">
<h2>Related guides</h2>
<ul>
<li><a href="/guides/raspberry-pi-digital-signage.html">How to set up digital signage on a Raspberry Pi</a></li>
<li><a href="/guides/digital-signage-android-tv.html">Free digital signage for Android TV and Fire TV</a></li>
<li><a href="/guides/self-hosted-digital-signage.html">Self-hosted digital signage: complete guide</a></li>
<li><a href="/compare/screencloud-alternative.html">Compare: ScreenTinker vs ScreenCloud</a></li>
<li><a href="/compare/optisigns-alternative.html">Compare: ScreenTinker vs OptiSigns</a></li>
</ul>
</div>
<div class="cta">
<h2>Try ScreenTinker free</h2>
<p>Start a 14-day Pro trial. No credit card required.</p>
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free Trial</a>
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">View on GitHub</a>
</div>
</main>
<footer>
<div style="color:var(--dim);font-size:13px">&copy; 2026 ScreenTinker. All rights reserved.</div>
<div class="links">
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
<a href="/legal/terms.html">Terms</a>
<a href="/legal/privacy.html">Privacy</a>
<a href="/legal/third-party.html">Licenses</a>
<a href="/app#/login">Sign In</a>
</div>
</footer>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
{ "@type": "ListItem", "position": 2, "name": "Compare", "item": "https://screentinker.com/#compare" },
{ "@type": "ListItem", "position": 3, "name": "Yodeck Alternative", "item": "https://screentinker.com/compare/yodeck-alternative.html" }
]
}
</script>
</body>
</html>

View file

@ -32,9 +32,191 @@ body {
font-size: 16px; font-size: 16px;
} }
/* Workspace switcher (Phase 3 MVP). Sits in sidebar-header below the logo.
Three render modes via JS: dropdown (>1 ws), static text (1 ws),
muted empty state (0 ws). */
.workspace-switcher { position: relative; margin-top: 12px; }
.workspace-switcher-button {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 8px 10px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text-primary);
font-size: 13px; cursor: pointer; transition: all var(--transition);
}
.workspace-switcher-button:hover { border-color: var(--accent); }
.workspace-switcher-static {
display: block; padding: 4px 2px;
color: var(--text-primary); font-size: 13px; font-weight: 500;
}
.workspace-switcher-static::before {
content: 'Workspace';
display: block;
font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-muted); margin-bottom: 2px;
}
.workspace-switcher-empty {
display: block; padding: 8px 10px;
color: var(--text-muted); font-size: 12px; font-style: italic;
}
/* #19: single-workspace view - name + always-visible manage icons (no dropdown). */
.workspace-switcher-single { display: flex; align-items: center; gap: 4px; }
.workspace-switcher-single .workspace-switcher-static { flex: 1; min-width: 0; }
.workspace-switcher-single .workspace-switcher-members,
.workspace-switcher-single .workspace-switcher-pencil { visibility: visible; align-self: end; }
.workspace-switcher-button .chev {
flex-shrink: 0; margin-left: 8px; color: var(--text-muted);
transition: transform var(--transition);
}
.workspace-switcher.open .chev { transform: rotate(180deg); }
.workspace-switcher-menu {
display: none;
/* Width: detach from the narrow sidebar-header (188px content width). The
sidebar is z-indexed and the dropdown is free to extend beyond the
sidebar into the main content area. min/max bounds keep it readable
for normal-length names without sprawling on extreme cases. */
position: absolute; top: calc(100% + 4px); left: 0;
min-width: 280px; max-width: 360px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
max-height: 360px; padding: 4px 0; overflow-y: auto; z-index: 100;
}
.workspace-switcher.open .workspace-switcher-menu { display: block; }
/* #16: sticky type-to-filter search header inside the (scrolling) menu. */
.workspace-switcher-search {
position: sticky; top: 0; z-index: 1;
background: var(--bg-card); padding: 8px;
border-bottom: 1px solid var(--border);
}
.workspace-switcher-search input {
width: 100%; box-sizing: border-box; padding: 6px 8px;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text-primary); font-size: 13px;
}
.workspace-switcher-search input:focus { outline: none; border-color: var(--accent); }
.workspace-switcher-noresults {
padding: 12px; color: var(--text-muted); font-size: 13px; text-align: center;
}
.workspace-switcher-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; cursor: pointer;
border-bottom: 1px solid var(--border);
color: var(--text-primary); font-size: 13px;
}
.workspace-switcher-item:last-child { border-bottom: none; }
.workspace-switcher-item:hover { background: var(--bg-input); }
/* keyboard-cursor highlight (arrow keys) - same surface as hover */
.workspace-switcher-item.highlighted { background: var(--bg-input); }
.workspace-switcher-item.current { font-weight: 600; }
.workspace-switcher-item .check {
flex-shrink: 0; color: var(--accent); width: 14px;
}
.workspace-switcher-item .ws-meta { flex: 1; min-width: 0; }
.workspace-switcher-item .ws-name {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.workspace-switcher-item .ws-org {
font-size: 11px; color: var(--text-muted); margin-top: 1px;
/* nowrap + ellipsis: long "Org Name . N devices" lines truncate cleanly
instead of wrapping onto a second line that doubles row height. */
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.workspace-switcher-pencil {
flex-shrink: 0; visibility: hidden;
background: none; border: none; padding: 4px;
color: var(--text-muted); cursor: pointer;
border-radius: 4px; transition: all var(--transition);
}
.workspace-switcher-item:hover .workspace-switcher-pencil { visibility: visible; }
.workspace-switcher-pencil:hover { color: var(--accent); background: var(--bg-input); }
/* Members icon - same shape as the pencil; navigates to #/workspace/:id/members. */
.workspace-switcher-members {
flex-shrink: 0; visibility: hidden;
background: none; border: none; padding: 4px;
color: var(--text-muted); cursor: pointer;
border-radius: 4px; transition: all var(--transition);
}
.workspace-switcher-item:hover .workspace-switcher-members { visibility: visible; }
.workspace-switcher-members:hover { color: var(--accent); background: var(--bg-input); }
/* Workspace members page (Phase 2 user-mgmt, slice 2A read-only). Three
sections render via JS: direct members, via_org access, pending invites.
Row layout mirrors the sidebar user card's avatar pattern for visual
continuity. via_org rows are opacity-reduced and invite rows use the
input-bg shade so the three states are distinguishable at a glance. */
.members-list { display: flex; flex-direction: column; gap: 4px; }
.member-row {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg-card);
}
.member-row--via-org { opacity: 0.75; }
.member-row--invited { background: var(--bg-input); }
.member-avatar {
flex-shrink: 0;
width: 32px; height: 32px; border-radius: 50%;
background: var(--accent); color: white;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600;
}
.member-avatar--muted { background: var(--text-muted); }
.member-meta { flex: 1; min-width: 0; }
.member-name {
font-size: 13px; font-weight: 500; color: var(--text-primary);
display: flex; align-items: center; gap: 8px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.member-email {
font-size: 11px; color: var(--text-muted); margin-top: 1px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.member-role {
flex-shrink: 0; font-size: 12px; color: var(--text-secondary);
padding: 4px 8px; background: var(--bg-input);
border-radius: 4px; min-width: 60px; text-align: center;
}
.member-detail {
flex-shrink: 0; font-size: 11px; color: var(--text-muted);
min-width: 110px; text-align: right;
}
.member-via-org { font-style: italic; }
.member-badge {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
padding: 2px 6px; background: var(--bg-input); color: var(--text-muted);
border-radius: 3px; font-weight: 600;
}
/* Slice 2B - mutation affordances. .member-actions is the cell that holds
per-row admin buttons (remove on direct-member rows, cancel on invited
rows). via_org rows omit the cell entirely. The role select replaces the
.member-role div for admins on direct-member rows. */
.member-role-select {
flex-shrink: 0; font-size: 12px;
background: var(--bg-input); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 4px;
padding: 4px 8px; min-width: 90px; cursor: pointer;
}
.member-role-select:hover { border-color: var(--accent); }
.member-actions {
flex-shrink: 0; display: flex; align-items: center; gap: 4px;
margin-left: 4px;
}
.member-action-btn {
display: inline-flex; align-items: center; justify-content: center;
background: none; border: none; padding: 6px;
color: var(--text-muted); cursor: pointer;
border-radius: 4px; transition: all var(--transition);
}
.member-action-btn:hover { background: var(--bg-input); }
.member-action-btn--danger:hover { color: var(--danger); }
.nav-links { .nav-links {
flex: 1; flex: 1;
padding: 12px 8px; padding: 12px 8px;
/* Scroll the nav when it's taller than the viewport (short screens, e.g.
1366x768) so items below the fold (Settings) stay reachable. min-height:0
is required for a flex child to shrink and scroll instead of overflowing. */
overflow-y: auto;
min-height: 0;
} }
.nav-link { .nav-link {
@ -237,6 +419,322 @@ body {
font-weight: 500; font-weight: 500;
} }
.device-card-select {
position: absolute;
top: 8px;
left: 8px;
z-index: 5;
background: rgba(0,0,0,0.6);
border-radius: 4px;
padding: 3px 5px;
display: flex;
align-items: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.device-card:hover .device-card-select,
.device-card.selected .device-card-select { opacity: 1; }
.device-card-select input { cursor: pointer; margin: 0; }
.device-card.selected { outline: 2px solid var(--primary, #3B82F6); outline-offset: -2px; }
/* Wall editor — free-form pan/zoom canvas */
.wall-viewport {
position: relative;
overflow: hidden;
cursor: grab;
user-select: none;
background:
linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px) 0 0 / 40px 40px,
linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px) 0 0 / 40px 40px,
var(--bg-primary);
}
.wall-viewport.panning { cursor: grabbing; }
/* Inner canvas: a 0×0 anchor whose CSS transform supplies pan + zoom.
All rect children are absolutely positioned in canvas-data coordinates
and inherit the parent transform. transform-origin is the canvas's
top-left so pan offsets map cleanly to data screen pixels. */
.wall-canvas {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
transform-origin: 0 0;
/* Disable transition so panning doesn't lag behind the cursor */
}
.wall-zoom-readout {
position: absolute;
bottom: 8px;
right: 12px;
background: rgba(0,0,0,0.65);
color: #fff;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
pointer-events: none;
font-variant-numeric: tabular-nums;
}
.wall-screen {
position: absolute;
background: rgba(59,130,246,0.08);
border: 2px solid var(--primary, #3B82F6);
border-radius: 4px;
box-sizing: border-box;
cursor: move;
user-select: none;
touch-action: none;
overflow: hidden;
}
.wall-screen-overlap {
position: absolute;
background: rgba(96,165,250,0.35);
pointer-events: none;
display: none;
z-index: 1;
}
.wall-screen-label {
position: absolute;
top: 4px;
left: 6px;
right: 24px;
pointer-events: none;
z-index: 2;
}
.wall-screen-name {
font-size: 12px;
font-weight: 600;
color: var(--text-primary, #fff);
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wall-screen-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: var(--text-muted);
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
}
.wall-screen-remove {
position: absolute;
top: 4px;
right: 4px;
z-index: 3;
width: 20px;
height: 20px;
background: rgba(0,0,0,0.6);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.wall-screen-remove:hover { background: var(--danger, #ef4444); }
.wall-player {
position: absolute;
background: rgba(96,165,250,0.18);
border: 2px dashed #60a5fa;
border-radius: 4px;
box-sizing: border-box;
cursor: move;
user-select: none;
touch-action: none;
z-index: 5;
box-shadow: 0 0 0 9999px transparent; /* keeps stacking explicit */
}
.wall-player-label {
position: absolute;
top: 4px;
left: 6px;
pointer-events: none;
color: #dbeafe;
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
font-size: 11px;
letter-spacing: 1px;
}
/* Selected rect highlight (works for both screens and the player) */
.wall-screen.selected,
.wall-player.selected {
outline: 3px solid #facc15;
outline-offset: 1px;
z-index: 6;
}
/* Fine-position panel inputs */
.wall-pos-grid {
display: grid;
grid-template-columns: auto 1fr auto 1fr;
gap: 6px 8px;
align-items: center;
font-size: 12px;
}
.wall-pos-grid label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wall-pos-grid input {
width: 100%;
padding: 4px 6px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary, #fff);
font: inherit;
font-variant-numeric: tabular-nums;
}
.wall-pos-grid input:focus { outline: 1px solid var(--primary); outline-offset: 0; border-color: var(--primary); }
/* Eight resize handles, used by both screens and the player */
.wall-handle {
position: absolute;
width: 10px;
height: 10px;
background: #fff;
border: 1px solid #1d4ed8;
border-radius: 2px;
z-index: 4;
}
.wall-player .wall-handle { border-color: #60a5fa; }
.wall-handle-nw { top: -5px; left: -5px; cursor: nw-resize; }
.wall-handle-n { top: -5px; left: 50%; transform: translateX(-50%); cursor: n-resize; }
.wall-handle-ne { top: -5px; right: -5px; cursor: ne-resize; }
.wall-handle-e { top: 50%; right: -5px; transform: translateY(-50%); cursor: e-resize; }
.wall-handle-se { bottom: -5px; right: -5px; cursor: se-resize; }
.wall-handle-s { bottom: -5px; left: 50%; transform: translateX(-50%); cursor: s-resize; }
.wall-handle-sw { bottom: -5px; left: -5px; cursor: sw-resize; }
.wall-handle-w { top: 50%; left: -5px; transform: translateY(-50%); cursor: w-resize; }
/* Wall editor — legacy cells (kept for migration; new editor uses wall-canvas) */
.wall-cell {
position: relative;
background: var(--bg-card);
border: 2px dashed var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text-secondary);
user-select: none;
}
.wall-cell.occupied {
background: rgba(59,130,246,0.15);
border: 2px solid var(--primary, #3B82F6);
cursor: grab;
}
.wall-cell.occupied:active { cursor: grabbing; }
.wall-cell.drag-over {
border-color: var(--success, #10b981);
box-shadow: 0 0 0 2px rgba(16,185,129,0.25) inset;
}
.wall-cell-name { font-weight: 500; padding: 0 6px; text-align: center; }
.wall-cell-pos {
position: absolute;
bottom: 4px;
font-size: 9px;
color: var(--text-muted);
letter-spacing: 0.5px;
}
.wall-cell-remove {
position: absolute;
top: 4px; right: 4px;
background: rgba(0,0,0,0.6);
border: none;
color: #fff;
border-radius: 50%;
width: 20px; height: 20px;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.wall-cell-remove:hover { background: var(--danger, #ef4444); }
.wall-card .wall-card-preview {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(139,92,246,0.15), rgba(59,130,246,0.1));
}
.wall-card-grid {
display: grid;
gap: 4px;
width: 65%;
aspect-ratio: 16/9;
padding: 8px;
}
.wall-card-cell {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(139,92,246,0.3);
border-radius: 2px;
}
.wall-card-cell.filled {
background: rgba(139,92,246,0.5);
border-color: rgba(139,92,246,0.9);
}
.device-card-progress {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 6px 10px 8px;
background: linear-gradient(to top, rgba(0,0,0,0.85), rgba(0,0,0,0));
color: #fff;
font-size: 11px;
pointer-events: none;
}
.device-card-progress-label {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
}
.device-card-progress-label .dcp-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.device-card-progress-label .dcp-time {
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
.device-card-progress-track {
height: 3px;
background: rgba(255,255,255,0.2);
border-radius: 2px;
overflow: hidden;
}
.device-card-progress-fill {
height: 100%;
width: 0%;
background: var(--primary, #3B82F6);
transition: width 0.9s linear;
}
.device-card-progress-fill.indeterminate {
background: linear-gradient(90deg, transparent, var(--primary, #3B82F6), transparent);
background-size: 50% 100%;
animation: dcp-indeterminate 1.4s linear infinite;
}
@keyframes dcp-indeterminate {
0% { background-position: -50% 0; }
100% { background-position: 150% 0; }
}
.device-card-body { .device-card-body {
padding: 14px 16px; padding: 14px 16px;
} }
@ -878,6 +1376,12 @@ body {
line-height: 1.4; line-height: 1.4;
} }
/* Table wrapper: enables horizontal scroll when table min-width exceeds viewport */
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Mobile hamburger toggle */ /* Mobile hamburger toggle */
.mobile-menu-btn { .mobile-menu-btn {
display: none; display: none;
@ -885,8 +1389,8 @@ body {
top: 12px; top: 12px;
left: 12px; left: 12px;
z-index: 200; z-index: 200;
width: 40px; width: 44px;
height: 40px; height: 44px;
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
@ -915,14 +1419,74 @@ body {
z-index: 140; z-index: 140;
} }
.sidebar-backdrop.open { display: block; } .sidebar-backdrop.open { display: block; }
.content { margin-left: 0; padding: 16px; padding-top: 60px; } .nav-link { min-height: 44px; padding: 10px 14px; }
.content { margin-left: 0; padding: 16px; padding-top: 68px; }
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; } .page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
.device-grid { grid-template-columns: 1fr; } .device-grid { grid-template-columns: 1fr; }
.content-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } .content-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.info-grid { grid-template-columns: 1fr 1fr; } .info-grid { grid-template-columns: 1fr; }
.remote-container { flex-direction: column; } .remote-container { flex-direction: column; }
.remote-controls { width: 100%; flex-direction: row; flex-wrap: wrap; } .remote-controls { width: 100%; flex-direction: row; flex-wrap: wrap; }
.modal { width: 95vw; max-height: 90vh; overflow-y: auto; } .modal { width: 95vw; max-height: 90vh; overflow-y: auto; }
.tabs { overflow-x: auto; } .tabs {
.tab { white-space: nowrap; } overflow-x: auto;
-webkit-overflow-scrolling: touch;
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
}
.tab { white-space: nowrap; flex-shrink: 0; }
.playlist-item { flex-wrap: wrap; }
/* Dashboard stats stack to single column */
.dash-stats-row { flex-direction: column; }
.dash-stats-row .info-card { flex: none; }
/* Content-library 3-up toolbar stacks vertically */
.content-toolbar { flex-direction: column; }
.content-toolbar > div[style*="width:320px"] { width: auto !important; }
/* Schedule controls: allow wrap and widen select to full row */
.schedule-controls { gap: 8px; }
.schedule-controls > select { flex: 1 1 100%; }
.schedule-controls > button,
.schedule-controls > span { flex: 0 1 auto; }
/* Tap targets: minimum 44px height for interactive elements */
.btn { min-height: 44px; padding: 10px 16px; }
.btn-sm { min-height: 36px; padding: 8px 12px; }
.btn-icon { min-width: 40px; min-height: 40px; }
/* Form inputs: 16px font to prevent iOS focus zoom; 44px tap target */
.input,
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
input[type="url"],
input[type="search"],
input[type="tel"],
select,
textarea {
font-size: 16px;
min-height: 44px;
}
.pairing-input { font-size: 24px; letter-spacing: 6px; }
/* Modals: adjust padding at 95vw so content doesn't touch edges */
.modal-header,
.modal-footer { padding: 14px 16px; }
.modal-body { padding: 16px; }
/* Toast container: full-width bar instead of 280px fixed to right */
.toast-container {
left: 12px;
right: 12px;
bottom: 12px;
}
.toast { min-width: 0; width: 100%; }
}
@media (max-width: 480px) {
.content-grid { grid-template-columns: 1fr; }
.assign-content-grid { grid-template-columns: 1fr 1fr; }
} }

87
frontend/css/seo-page.css Normal file
View file

@ -0,0 +1,87 @@
/* Shared styles for SEO landing pages: comparison and guide pages.
Matches the dark theme of landing.html. */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root { --accent:#3b82f6; --bg:#111827; --card:#1e293b; --border:#334155; --text:#f1f5f9; --muted:#94a3b8; --dim:#64748b; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.65; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Nav (matches landing.html) */
nav { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(17,24,39,0.9); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); }
.nav-inner { max-width: 1200px; margin: 0 auto; padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
.nav-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; color: var(--accent); flex-shrink: 0; }
.nav-logo a { color: var(--accent); }
.nav-links { display: flex; align-items: center; flex-wrap: nowrap; }
.nav-links a { color: var(--muted); margin-left: 24px; font-size: 14px; transition: color 0.2s; }
.nav-links a:hover { color: var(--text); text-decoration: none; }
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border-radius: 8px; font-weight: 600; font-size: 14px; transition: all 0.2s; border: none; cursor: pointer; }
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: #2563eb; text-decoration: none; }
.btn-outline { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
.btn-outline:hover { background: rgba(59,130,246,0.1); text-decoration: none; }
/* Article container */
.article { max-width: 880px; margin: 0 auto; padding: 120px 24px 60px; }
.breadcrumb { font-size: 13px; color: var(--muted); margin-bottom: 24px; }
.breadcrumb a { color: var(--muted); }
.breadcrumb a:hover { color: var(--text); }
.breadcrumb span { margin: 0 8px; color: var(--dim); }
.article h1 { font-size: clamp(30px, 4vw, 44px); font-weight: 800; line-height: 1.2; margin-bottom: 16px; }
.article .lead { font-size: 18px; color: var(--muted); margin-bottom: 32px; }
.article h2 { font-size: 28px; font-weight: 700; margin: 48px 0 16px; line-height: 1.3; }
.article h3 { font-size: 20px; font-weight: 600; margin: 28px 0 12px; }
.article p { margin-bottom: 16px; color: var(--text); }
.article ul, .article ol { margin: 0 0 16px 24px; color: var(--text); }
.article li { margin-bottom: 8px; }
.article strong { color: var(--text); font-weight: 600; }
.article code { background: var(--card); border: 1px solid var(--border); padding: 2px 6px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; color: #e2e8f0; }
.article pre { background: var(--card); border: 1px solid var(--border); padding: 16px; border-radius: 8px; overflow-x: auto; margin: 16px 0; }
.article pre code { background: transparent; border: none; padding: 0; font-size: 13px; }
.article blockquote { border-left: 3px solid var(--accent); padding: 8px 16px; margin: 16px 0; color: var(--muted); background: rgba(59,130,246,0.06); border-radius: 4px; }
/* Comparison table */
.compare-table-wrap { width: 100%; overflow-x: auto; margin: 24px 0; -webkit-overflow-scrolling: touch; }
.compare-table { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 640px; }
.compare-table th, .compare-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); }
.compare-table th { color: var(--text); font-weight: 600; background: var(--card); }
.compare-table td:first-child { color: var(--muted); }
.compare-table .yes { color: #22c55e; font-weight: 600; }
.compare-table .no { color: #ef4444; }
.compare-table .partial { color: #f59e0b; }
.compare-table tbody tr:hover { background: rgba(59,130,246,0.04); }
/* CTA */
.cta { text-align: center; padding: 60px 24px; background: linear-gradient(135deg, rgba(59,130,246,0.1), rgba(139,92,246,0.1)); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); margin: 48px 0; border-radius: 12px; }
.cta h2 { font-size: 28px; margin: 0 0 12px; }
.cta p { color: var(--muted); margin-bottom: 20px; font-size: 17px; }
/* Related links / internal linking block */
.related { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin: 32px 0; }
.related h2 { margin: 0 0 12px; font-size: 20px; }
.related ul { margin: 0; list-style: none; }
.related li { margin: 8px 0; padding-left: 0; }
.related li::before { content: '> '; color: var(--accent); font-weight: 700; }
/* Footer (matches landing.html) */
footer { max-width: 1200px; margin: 0 auto; padding: 40px 24px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; border-top: 1px solid var(--border); }
footer .links a { color: var(--dim); margin-left: 16px; font-size: 13px; }
footer .links a:hover { color: var(--text); text-decoration: none; }
/* Mobile */
@media (max-width: 768px) {
.nav-links a:not(.btn) { display: none; }
.nav-inner { padding: 12px 14px; gap: 8px; }
.nav-links .btn { padding: 8px 12px; font-size: 13px; margin-left: 8px; flex-shrink: 0; min-height: 0; }
.btn { min-height: 44px; }
.article { padding: 100px 16px 40px; }
.cta { padding: 40px 16px; }
footer { flex-direction: column; text-align: center; }
footer .links a { margin: 4px 8px; }
.compare-table { font-size: 12px; }
.compare-table th, .compare-table td { padding: 8px; }
}
@media (max-width: 420px) {
.nav-logo-text { display: none; }
}

View file

@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Free Digital Signage for Android TV & Fire TV (2026) | ScreenTinker</title>
<meta name="description" content="Turn any Android TV box or Amazon Fire Stick into a digital signage display with ScreenTinker. Free APK, kiosk mode, remote control. Step-by-step guide for 2026.">
<meta name="keywords" content="digital signage android tv, fire tv signage, android tv kiosk, fire stick digital signage, free android tv signage, open source android tv signage">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://screentinker.com/guides/digital-signage-android-tv.html">
<meta property="og:type" content="article">
<meta property="og:url" content="https://screentinker.com/guides/digital-signage-android-tv.html">
<meta property="og:title" content="Free Digital Signage for Android TV & Fire TV (2026)">
<meta property="og:description" content="Turn any Android TV or Fire Stick into a digital signage player. Free APK, kiosk mode, remote control.">
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta property="og:site_name" content="ScreenTinker">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Free Digital Signage for Android TV & Fire TV (2026)">
<meta name="twitter:description" content="Turn any Android TV or Fire Stick into a digital signage player. Free APK, kiosk mode, remote control.">
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta name="theme-color" content="#111827">
<link rel="icon" href="/assets/icon-192.png">
<link rel="apple-touch-icon" href="/assets/icon-192.png">
<link rel="stylesheet" href="/css/seo-page.css">
</head>
<body>
<nav>
<div class="nav-inner">
<div class="nav-logo">
<a href="/" style="display:flex;align-items:center;gap:10px">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<span class="nav-logo-text">ScreenTinker</span>
</a>
</div>
<div class="nav-links">
<a href="/#features">Features</a>
<a href="/#pricing">Pricing</a>
<a href="/#compare">Compare</a>
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
</div>
</div>
</nav>
<main class="article">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Home</a>
<span>/</span>
<a href="/#features">Guides</a>
<span>/</span>
<span>Android TV & Fire TV Signage</span>
</nav>
<h1>Free Digital Signage for Android TV and Fire TV (2026)</h1>
<p class="lead">Turn any Android TV box, Apolosign player, or Amazon Fire Stick into a fully managed digital signage display using the free ScreenTinker APK.</p>
<h2>What works</h2>
<ul>
<li><strong>Android TV</strong> (Sony, Hisense, Philips, Onn, NVIDIA Shield, generic Android TV boxes)</li>
<li><strong>Amazon Fire TV / Fire Stick</strong> (4K, 4K Max, Cube)</li>
<li><strong>Apolosign signage players</strong> (Android-based commercial signage hardware)</li>
<li><strong>Tablets running Android 8+</strong> mounted as in-store displays</li>
</ul>
<h2>Step 1: Get the APK</h2>
<p>Download the ScreenTinker APK from <a href="/download/apk">screentinker.com/download/apk</a>. The latest signed release is hosted directly so you do not need a Play Store or App Store account.</p>
<h2>Step 2: Sideload onto the device</h2>
<h3>On Android TV</h3>
<p>The easiest path is to install the <strong>Downloader</strong> app from the Google Play Store on the TV, then enter the URL <code>https://screentinker.com/download/apk</code>. Downloader fetches the APK and walks you through installing it. You will be prompted once to "allow installs from this source" - say yes.</p>
<h3>On Fire TV / Fire Stick</h3>
<p>Install <strong>Downloader</strong> from the Amazon App Store. In Settings &gt; My Fire TV &gt; Developer Options, enable <strong>Apps from Unknown Sources</strong>. Open Downloader, enter <code>https://screentinker.com/download/apk</code>, and install.</p>
<h3>On Apolosign / commercial Android signage hardware</h3>
<p>These devices typically expose a system file manager. Plug in a USB drive containing the APK, open the file manager, and tap the APK to install. Some Apolosign units allow direct URL install via the built-in browser.</p>
<h2>Step 3: Pair the device</h2>
<p>Launch the ScreenTinker app. The first time it runs, you will be asked to grant a few permissions:</p>
<ul>
<li><strong>Display over other apps</strong> - so the player can stay fullscreen</li>
<li><strong>Storage</strong> - for the local content cache</li>
<li><strong>Accessibility service</strong> (optional) - enables remote touch and key injection from the dashboard</li>
</ul>
<p>The app will then show a 6-digit pairing code. Sign in to your <a href="/app#/login">ScreenTinker dashboard</a>, click <strong>+ Add Display</strong>, and enter the code.</p>
<h2>Step 4: Push content</h2>
<p>Same as any other ScreenTinker display:</p>
<ol>
<li>Upload media in the <strong>Content Library</strong></li>
<li>Build a <strong>Playlist</strong></li>
<li>Publish the playlist and assign it to your device</li>
</ol>
<h2>Kiosk mode tips</h2>
<p>For unattended displays you generally want the device to boot straight into the player with no way for someone to back out:</p>
<ul>
<li><strong>Set ScreenTinker as the launcher.</strong> The APK declares <code>HOME</code> intent support, so on most Android TVs you can pick it as the default launcher in Settings &gt; Apps.</li>
<li><strong>Disable updates and notifications</strong> on the device to prevent unwanted popups.</li>
<li><strong>Enable auto-power-on</strong> in TV settings if you want the display to come back after a power blip without manual intervention.</li>
<li><strong>For Fire Stick,</strong> use the Wolf Launcher or similar to replace the Amazon home screen.</li>
</ul>
<h2>Hardware recommendations</h2>
<ul>
<li><strong>Lowest cost:</strong> Amazon Fire TV Stick 4K. ~$50 and works fine for image and 1080p video playlists.</li>
<li><strong>Best value:</strong> Onn 4K Streaming Box. ~$30 at Walmart and runs Android TV stock.</li>
<li><strong>Commercial:</strong> Apolosign players. Ship with built-in mount, real-time clock, and HDMI-CEC for power management. Recommended for production deployments.</li>
<li><strong>Highest performance:</strong> NVIDIA Shield TV. Overkill for signage but bulletproof.</li>
</ul>
<div class="related">
<h2>Related guides</h2>
<ul>
<li><a href="/guides/raspberry-pi-digital-signage.html">How to set up digital signage on a Raspberry Pi</a></li>
<li><a href="/guides/self-hosted-digital-signage.html">Self-hosted digital signage: complete guide</a></li>
<li><a href="/compare/yodeck-alternative.html">Compare: ScreenTinker vs Yodeck</a></li>
<li><a href="/compare/screencloud-alternative.html">Compare: ScreenTinker vs ScreenCloud</a></li>
</ul>
</div>
<div class="cta">
<h2>Ready to deploy?</h2>
<p>Free plan supports 1 device. Pro trial unlocks 15 devices for 14 days, no credit card.</p>
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free</a>
<a href="/download/apk" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">Download APK</a>
</div>
</main>
<footer>
<div style="color:var(--dim);font-size:13px">&copy; 2026 ScreenTinker. All rights reserved.</div>
<div class="links">
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
<a href="/legal/terms.html">Terms</a>
<a href="/legal/privacy.html">Privacy</a>
<a href="/legal/third-party.html">Licenses</a>
<a href="/app#/login">Sign In</a>
</div>
</footer>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
{ "@type": "ListItem", "position": 2, "name": "Guides", "item": "https://screentinker.com/#features" },
{ "@type": "ListItem", "position": 3, "name": "Android TV and Fire TV Signage", "item": "https://screentinker.com/guides/digital-signage-android-tv.html" }
]
}
</script>
</body>
</html>

View file

@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>How to Set Up Digital Signage on Raspberry Pi (2026) | ScreenTinker</title>
<meta name="description" content="Step-by-step guide to building a Raspberry Pi digital signage player with ScreenTinker. Covers hardware, Pi OS setup, the install script, pairing, and pushing content. Free and open source.">
<meta name="keywords" content="raspberry pi digital signage, digital signage raspberry pi, pi signage, raspberry pi tv display, free pi signage software, open source pi signage">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://screentinker.com/guides/raspberry-pi-digital-signage.html">
<meta property="og:type" content="article">
<meta property="og:url" content="https://screentinker.com/guides/raspberry-pi-digital-signage.html">
<meta property="og:title" content="How to Set Up Digital Signage on Raspberry Pi (2026)">
<meta property="og:description" content="Step-by-step Pi signage guide. Hardware, OS, install script, pairing, content. Free and open source.">
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta property="og:site_name" content="ScreenTinker">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="How to Set Up Digital Signage on Raspberry Pi (2026)">
<meta name="twitter:description" content="Step-by-step Pi signage guide. Hardware, OS, install script, pairing, content. Free and open source.">
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta name="theme-color" content="#111827">
<link rel="icon" href="/assets/icon-192.png">
<link rel="apple-touch-icon" href="/assets/icon-192.png">
<link rel="stylesheet" href="/css/seo-page.css">
</head>
<body>
<nav>
<div class="nav-inner">
<div class="nav-logo">
<a href="/" style="display:flex;align-items:center;gap:10px">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<span class="nav-logo-text">ScreenTinker</span>
</a>
</div>
<div class="nav-links">
<a href="/#features">Features</a>
<a href="/#pricing">Pricing</a>
<a href="/#compare">Compare</a>
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
</div>
</div>
</nav>
<main class="article">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Home</a>
<span>/</span>
<a href="/#features">Guides</a>
<span>/</span>
<span>Raspberry Pi Digital Signage</span>
</nav>
<h1>How to Set Up Digital Signage on a Raspberry Pi (2026)</h1>
<p class="lead">A step-by-step guide to turning a Raspberry Pi into a free, open-source digital signage player using ScreenTinker. Works on Pi 3, Pi 4, and Pi 5.</p>
<h2>What you will need</h2>
<ul>
<li><strong>Raspberry Pi 3, Pi 4, or Pi 5.</strong> Pi 4 (4GB+) is the sweet spot. Pi 3 works for static images and 1080p video. Pi 5 is overkill but futureproof.</li>
<li><strong>microSD card,</strong> 16 GB or larger. Class 10 or A1/A2 rated.</li>
<li><strong>Power supply</strong> appropriate for your model (Pi 4 uses USB-C 15W; Pi 5 uses USB-C 27W).</li>
<li><strong>HDMI cable</strong> to your TV or monitor (micro-HDMI on Pi 4/5).</li>
<li><strong>Network connection</strong> - Ethernet preferred for reliability, Wi-Fi works fine.</li>
<li><strong>A ScreenTinker account.</strong> <a href="/app#/login">Sign up free</a> if you do not have one.</li>
</ul>
<h2>Step 1: Install Raspberry Pi OS</h2>
<p>Use <a href="https://www.raspberrypi.com/software/" target="_blank" rel="noopener">Raspberry Pi Imager</a> to flash <strong>Raspberry Pi OS (64-bit)</strong> to your microSD card. Choose the standard Desktop edition (not Lite - we need a desktop environment for the browser).</p>
<p>In the Imager's advanced options (gear icon), pre-set:</p>
<ul>
<li>Hostname (e.g. <code>signage-lobby</code>)</li>
<li>Username and password</li>
<li>Wi-Fi credentials (if not using Ethernet)</li>
<li>Enable SSH (optional but useful for remote management)</li>
</ul>
<p>Insert the SD card, plug in the Pi, and let it boot through first-time setup.</p>
<h2>Step 2: Run the ScreenTinker installer</h2>
<p>Open a terminal on the Pi and run:</p>
<pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | bash</code></pre>
<p>The script will:</p>
<ul>
<li>Install Chromium (the kiosk browser used as the player)</li>
<li>Set up an autostart entry so the player launches in fullscreen on boot</li>
<li>Disable screen blanking and the screensaver</li>
<li>Configure HDMI to keep the display awake</li>
<li>Reboot the Pi when finished</li>
</ul>
<p>On reboot the Pi will launch directly into the ScreenTinker player and show a 6-digit pairing code.</p>
<h2>Step 3: Pair the Pi to your dashboard</h2>
<p>Sign in to <a href="/app#/login">your ScreenTinker dashboard</a> and click <strong>+ Add Display</strong>. Enter the 6-digit code shown on the Pi and give the display a name (e.g. "Lobby TV"). The Pi will switch from the pairing screen to "Waiting for content".</p>
<h2>Step 4: Push content</h2>
<p>From the dashboard:</p>
<ol>
<li>Open <strong>Content Library</strong> and upload an image, video, or paste a remote URL.</li>
<li>Open <strong>Playlists</strong>, create a playlist, and add items.</li>
<li>Publish the playlist.</li>
<li>From the device's detail page, assign the playlist.</li>
</ol>
<p>The Pi picks up the new playlist within a few seconds and starts playing.</p>
<h2>Performance tips</h2>
<ul>
<li><strong>Use H.264 video.</strong> Pi GPUs accelerate H.264 in hardware. H.265/HEVC works on Pi 4/5 but uses more CPU.</li>
<li><strong>Match your resolution to the display.</strong> 1080p video on a 1080p screen avoids unnecessary scaling.</li>
<li><strong>Wired Ethernet is more reliable than Wi-Fi</strong> for video-heavy playlists. Wi-Fi is fine for image-heavy ones.</li>
<li><strong>For Pi 3,</strong> stick to images and short clips. Pi 3 can struggle with continuous 1080p video.</li>
</ul>
<h2>Troubleshooting</h2>
<h3>The Pi reboots into the desktop, not the player</h3>
<p>Check that the autostart file <code>~/.config/autostart/screentinker.desktop</code> exists. The installer creates this; if it's missing, re-run the installer.</p>
<h3>The screen goes dark after a few minutes</h3>
<p>The installer should disable screen blanking, but some monitors sleep based on their own timer. Disable sleep mode on the monitor itself, or use a dummy HDMI plug if the Pi negotiates a low-power mode.</p>
<h3>The Pi shows the pairing code but I can't see it on the dashboard</h3>
<p>The pairing code is shown on the Pi screen, not the dashboard. Sign in, click Add Display, and type the code from the Pi.</p>
<div class="related">
<h2>Related guides</h2>
<ul>
<li><a href="/guides/digital-signage-android-tv.html">Digital signage for Android TV and Fire TV</a></li>
<li><a href="/guides/self-hosted-digital-signage.html">Self-hosted digital signage: complete guide</a></li>
<li><a href="/compare/yodeck-alternative.html">Compare: ScreenTinker vs Yodeck</a></li>
<li><a href="/compare/screencloud-alternative.html">Compare: ScreenTinker vs ScreenCloud</a></li>
</ul>
</div>
<div class="cta">
<h2>Ready to set up your Pi?</h2>
<p>Start a free ScreenTinker account in under a minute.</p>
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free</a>
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">View on GitHub</a>
</div>
</main>
<footer>
<div style="color:var(--dim);font-size:13px">&copy; 2026 ScreenTinker. All rights reserved.</div>
<div class="links">
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
<a href="/legal/terms.html">Terms</a>
<a href="/legal/privacy.html">Privacy</a>
<a href="/legal/third-party.html">Licenses</a>
<a href="/app#/login">Sign In</a>
</div>
</footer>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
{ "@type": "ListItem", "position": 2, "name": "Guides", "item": "https://screentinker.com/#features" },
{ "@type": "ListItem", "position": 3, "name": "Raspberry Pi Digital Signage", "item": "https://screentinker.com/guides/raspberry-pi-digital-signage.html" }
]
}
</script>
</body>
</html>

View file

@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Self-Hosted Digital Signage Software - Complete Guide (2026) | ScreenTinker</title>
<meta name="description" content="Why and how to self-host your digital signage CMS. Data privacy, cost control, and no recurring fees. Complete deployment guide using ScreenTinker - open source, MIT licensed.">
<meta name="keywords" content="self hosted digital signage, on premise digital signage, self hosted signage cms, open source signage server, deploy signage on premise, private digital signage">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://screentinker.com/guides/self-hosted-digital-signage.html">
<meta property="og:type" content="article">
<meta property="og:url" content="https://screentinker.com/guides/self-hosted-digital-signage.html">
<meta property="og:title" content="Self-Hosted Digital Signage Software - Complete Guide (2026)">
<meta property="og:description" content="Why and how to self-host your digital signage CMS. Data privacy, cost control, no recurring fees.">
<meta property="og:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta property="og:site_name" content="ScreenTinker">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Self-Hosted Digital Signage Software - Complete Guide (2026)">
<meta name="twitter:description" content="Why and how to self-host your digital signage CMS. Data privacy, cost control, no recurring fees.">
<meta name="twitter:image" content="https://screentinker.com/assets/dashboard-preview.png">
<meta name="theme-color" content="#111827">
<link rel="icon" href="/assets/icon-192.png">
<link rel="apple-touch-icon" href="/assets/icon-192.png">
<link rel="stylesheet" href="/css/seo-page.css">
</head>
<body>
<nav>
<div class="nav-inner">
<div class="nav-logo">
<a href="/" style="display:flex;align-items:center;gap:10px">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<span class="nav-logo-text">ScreenTinker</span>
</a>
</div>
<div class="nav-links">
<a href="/#features">Features</a>
<a href="/#pricing">Pricing</a>
<a href="/#compare">Compare</a>
<a href="/app#/login" class="btn btn-outline" style="margin-left:16px">Sign In</a>
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Try Free</a>
</div>
</div>
</nav>
<main class="article">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Home</a>
<span>/</span>
<a href="/#features">Guides</a>
<span>/</span>
<span>Self-Hosted Digital Signage</span>
</nav>
<h1>Self-Hosted Digital Signage Software: Complete Guide (2026)</h1>
<p class="lead">Why you might want to self-host your digital signage CMS, what you need to do it well, and how to deploy ScreenTinker on your own server.</p>
<h2>Why self-host digital signage?</h2>
<p>Most digital signage products are cloud-only. That works for many businesses, but there are real reasons to keep the server in-house:</p>
<ul>
<li><strong>Data sovereignty.</strong> Healthcare, finance, government, and education often cannot put internal information into a third-party cloud. Self-hosting keeps content, schedules, and access logs on your network.</li>
<li><strong>Cost control.</strong> Per-screen monthly fees stack up fast. Self-hosting trades that for a fixed server cost - typically $5 to $50 per month for a small VPS that can run hundreds of screens.</li>
<li><strong>Network isolation.</strong> Some deployments live on private LANs with no internet access at all. Self-hosting is the only way to manage signage in those environments.</li>
<li><strong>No vendor lock-in.</strong> If the cloud vendor disappears, raises prices 3x, or pivots away from your use case, your deployment goes with them. Self-hosters control their own roadmap.</li>
<li><strong>Customization.</strong> Open source self-hosted means you can fork the code, add a custom widget, or wire it into your existing systems.</li>
</ul>
<h2>What you need</h2>
<h3>Hardware / VPS</h3>
<p>A modest Linux server is enough for most deployments:</p>
<ul>
<li><strong>Up to 25 displays:</strong> 1 vCPU, 1 GB RAM, 20 GB disk. ~$5/month on Hetzner, DigitalOcean, or Vultr.</li>
<li><strong>25-100 displays:</strong> 2 vCPU, 2 GB RAM, 40 GB disk. ~$12-20/month.</li>
<li><strong>100+ displays:</strong> 4+ vCPU, 4+ GB RAM, faster disk. Plan for content storage at ~50-200 MB per screen depending on media volume.</li>
</ul>
<p>An on-prem VM works just as well as a cloud VPS - in fact, on-prem is often the whole point.</p>
<h3>Software prerequisites</h3>
<ul>
<li>Ubuntu 22.04 or 24.04 LTS (Debian 12 also works)</li>
<li>Node.js 18 or newer</li>
<li>A domain name pointed at your server (or just an internal hostname / IP for LAN deployments)</li>
<li>SSL certificate (Let's Encrypt is free; or self-signed for LAN)</li>
</ul>
<h2>Deploying ScreenTinker</h2>
<p>Detailed setup is in the <a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub README</a>. Quick version:</p>
<pre><code>git clone https://github.com/screentinker/screentinker.git
cd screentinker/server
npm install
cp .env.example .env
# edit .env with your domain, JWT_SECRET, and SELF_HOSTED=true
node server.js</code></pre>
<p>Set <code>SELF_HOSTED=true</code> in the env. This unlocks the enterprise plan for your account, disables subscription expiry checks, and skips Stripe entirely. It is meant for the operator-controlled deployment case.</p>
<h2>Reverse proxy and TLS</h2>
<p>ScreenTinker listens on HTTP/HTTPS directly, but in production you typically front it with nginx or Caddy for TLS termination, gzip, and rate limiting. A minimal Caddyfile:</p>
<pre><code>signage.example.com {
reverse_proxy localhost:3001
}</code></pre>
<p>Caddy handles Let's Encrypt automatically. nginx works too if your team prefers it.</p>
<h2>Running as a service</h2>
<p>Use systemd to keep the process alive across reboots. A unit file at <code>/etc/systemd/system/screentinker.service</code>:</p>
<pre><code>[Unit]
Description=ScreenTinker Digital Signage Server
After=network.target
[Service]
WorkingDirectory=/opt/screentinker/server
ExecStart=/usr/bin/node server.js
EnvironmentFile=/opt/screentinker/.env
Restart=always
User=screentinker
[Install]
WantedBy=multi-user.target</code></pre>
<p>Enable with <code>systemctl enable --now screentinker</code>.</p>
<h2>Backups</h2>
<p>The state lives in two places:</p>
<ul>
<li><code>server/db/remote_display.db</code> - SQLite database of users, devices, playlists, schedules</li>
<li><code>server/uploads/</code> - uploaded media (images, videos, thumbnails)</li>
</ul>
<p>A nightly tarball of those two paths gives you a full restore point. Pair with offsite sync (rclone, restic) for disaster recovery.</p>
<h2>Self-hosted vs cloud-hosted comparison</h2>
<div class="compare-table-wrap">
<table class="compare-table">
<thead>
<tr><th>Concern</th><th>ScreenTinker self-hosted</th><th>Cloud-only signage products</th></tr>
</thead>
<tbody>
<tr><td>Data location</td><td>Your server</td><td>Vendor's cloud</td></tr>
<tr><td>Recurring per-screen cost</td><td>None</td><td>$5-15/screen/month</td></tr>
<tr><td>Server cost</td><td>$5-50/month flat</td><td>None (included)</td></tr>
<tr><td>Internet required for management</td><td>No (LAN works)</td><td>Yes</td></tr>
<tr><td>Source code access</td><td>Yes (MIT)</td><td>Closed</td></tr>
<tr><td>Air-gapped deployment</td><td>Possible</td><td>Not possible</td></tr>
<tr><td>Vendor lock-in risk</td><td>None (you own it)</td><td>High</td></tr>
<tr><td>Update / patch responsibility</td><td>Yours</td><td>Vendor</td></tr>
<tr><td>Initial setup time</td><td>~1 hour</td><td>~5 minutes</td></tr>
</tbody>
</table>
</div>
<h2>When the cloud is the right answer</h2>
<p>Self-hosting is not free of cost - it requires someone who can run a Linux server, monitor it, and apply security updates. If your screen count is small (under ~10) and you do not have IT capacity, the managed cloud version is probably the right choice. ScreenTinker's hosted plans start at $39/mo for 5 devices.</p>
<div class="related">
<h2>Related guides</h2>
<ul>
<li><a href="/guides/raspberry-pi-digital-signage.html">How to set up digital signage on a Raspberry Pi</a></li>
<li><a href="/guides/digital-signage-android-tv.html">Free digital signage for Android TV and Fire TV</a></li>
<li><a href="/compare/yodeck-alternative.html">Compare: ScreenTinker vs Yodeck</a></li>
<li><a href="/compare/screencloud-alternative.html">Compare: ScreenTinker vs ScreenCloud</a></li>
<li><a href="/compare/optisigns-alternative.html">Compare: ScreenTinker vs OptiSigns</a></li>
</ul>
</div>
<div class="cta">
<h2>Try the cloud version first</h2>
<p>Use the hosted version to get familiar, then deploy on your own server when you are ready.</p>
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free</a>
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener" class="btn btn-outline" style="padding:14px 28px;font-size:16px;margin-left:12px">View on GitHub</a>
</div>
</main>
<footer>
<div style="color:var(--dim);font-size:13px">&copy; 2026 ScreenTinker. All rights reserved.</div>
<div class="links">
<a href="https://github.com/screentinker/screentinker" target="_blank" rel="noopener">GitHub</a>
<a href="https://discord.gg/utTdsrqq4Z" target="_blank" rel="noopener">Discord</a>
<a href="/legal/terms.html">Terms</a>
<a href="/legal/privacy.html">Privacy</a>
<a href="/legal/third-party.html">Licenses</a>
<a href="/app#/login">Sign In</a>
</div>
</footer>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://screentinker.com/" },
{ "@type": "ListItem", "position": 2, "name": "Guides", "item": "https://screentinker.com/#features" },
{ "@type": "ListItem", "position": 3, "name": "Self-Hosted Digital Signage", "item": "https://screentinker.com/guides/self-hosted-digital-signage.html" }
]
}
</script>
</body>
</html>

View file

@ -17,11 +17,11 @@
<!-- OAuth providers loaded on-demand by login.js when needed --> <!-- OAuth providers loaded on-demand by login.js when needed -->
</head> </head>
<body> <body>
<button class="mobile-menu-btn" id="mobileMenuBtn" onclick="document.querySelector('.sidebar').classList.toggle('open');document.getElementById('sidebarBackdrop').classList.toggle('open')"> <button class="mobile-menu-btn" id="mobileMenuBtn" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="sidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button> </button>
<div class="sidebar-backdrop" id="sidebarBackdrop" onclick="document.querySelector('.sidebar').classList.remove('open');this.classList.remove('open')"></div> <div class="sidebar-backdrop" id="sidebarBackdrop"></div>
<nav class="sidebar"> <nav class="sidebar" id="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo"> <div class="logo">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -29,8 +29,11 @@
<line x1="8" y1="21" x2="16" y2="21"/> <line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/> <line x1="12" y1="17" x2="12" y2="21"/>
</svg> </svg>
<span>ScreenTinker</span> <span id="brandName">ScreenTinker</span>
</div> </div>
<!-- #38: apply cached white-label before first paint (no ScreenTinker flash) -->
<script src="/js/brand-prime.js"></script>
<div class="workspace-switcher" id="workspaceSwitcher"></div>
</div> </div>
<ul class="nav-links"> <ul class="nav-links">
<li><a href="#/" class="nav-link active" data-view="dashboard"> <li><a href="#/" class="nav-link active" data-view="dashboard">
@ -106,7 +109,10 @@
</svg> </svg>
<span>Activity</span> <span>Activity</span>
</a></li> </a></li>
<li><a href="#/teams" class="nav-link" data-view="teams"> <!-- Teams nav hidden while the feature is being redesigned as a user-grouping
primitive within Workspaces. Route + view kept in place so any existing
bookmark still loads (and shows the 503 from the API). -->
<li style="display:none"><a href="#/teams" class="nav-link" data-view="teams">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/> <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/> <path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
@ -156,45 +162,45 @@
<div class="modal-overlay" id="addDeviceModal" style="display:none"> <div class="modal-overlay" id="addDeviceModal" style="display:none">
<div class="modal" style="max-width:560px"> <div class="modal" style="max-width:560px">
<div class="modal-header"> <div class="modal-header">
<h3>Add Display</h3> <h3 data-i18n="add_display.title">Add Display</h3>
<button class="btn-icon" onclick="document.getElementById('addDeviceModal').style.display='none'"> <button class="btn-icon" data-close-modal="addDeviceModal">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg> </svg>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="modal-description" style="margin-bottom:16px">Enter the 6-digit pairing code shown on the display.</p> <p class="modal-description" style="margin-bottom:16px" data-i18n="add_display.intro">Enter the 6-digit pairing code shown on the display.</p>
<div class="form-group"> <div class="form-group">
<label>Pairing Code</label> <label data-i18n="add_display.pairing_code">Pairing Code</label>
<input type="text" id="pairingCodeInput" maxlength="6" pattern="[0-9]{6}" placeholder="000000" class="pairing-input"> <input type="text" id="pairingCodeInput" maxlength="6" pattern="[0-9]{6}" placeholder="000000" class="pairing-input">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Display Name (optional)</label> <label data-i18n="add_display.display_name">Display Name (optional)</label>
<input type="text" id="deviceNameInput" placeholder="e.g., Lobby TV" class="input"> <input type="text" id="deviceNameInput" data-i18n-placeholder="add_display.name_placeholder" placeholder="e.g., Lobby TV" class="input">
</div> </div>
<div style="border-top:1px solid var(--border,#1e293b);margin-top:20px;padding-top:16px"> <div style="border-top:1px solid var(--border,#1e293b);margin-top:20px;padding-top:16px">
<p style="font-size:12px;color:var(--text-muted,#64748b);margin-bottom:10px;font-weight:500">Need a player app? Install one to get a pairing code:</p> <p style="font-size:12px;color:var(--text-muted,#64748b);margin-bottom:10px;font-weight:500" data-i18n="add_display.need_player">Need a player app? Install one to get a pairing code:</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<a href="/download/apk" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px"> <a href="/download/apk" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
&#129302; Android APK &#129302; <span data-i18n="add_display.android_apk">Android APK</span>
</a> </a>
<a href="/player" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px"> <a href="/player" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
&#127760; Web Player &#127760; <span data-i18n="add_display.web_player">Web Player</span>
</a> </a>
<a href="/scripts/raspberry-pi-setup.sh" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px"> <a href="/scripts/raspberry-pi-setup.sh" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
&#129359; Raspberry Pi &#129359; <span data-i18n="add_display.raspberry_pi">Raspberry Pi</span>
</a> </a>
<a href="/scripts/windows-setup.bat" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px"> <a href="/scripts/windows-setup.bat" class="btn btn-secondary btn-sm" style="text-decoration:none;justify-content:center;font-size:12px">
&#128187; Windows &#128187; <span data-i18n="add_display.windows">Windows</span>
</a> </a>
</div> </div>
<p style="font-size:11px;color:var(--text-muted,#64748b);margin-top:8px">Smart TVs (LG/Samsung): open the built-in browser and navigate to <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code></p> <p style="font-size:11px;color:var(--text-muted,#64748b);margin-top:8px" data-i18n-html="add_display.smart_tv_note">Smart TVs (LG/Samsung): open the built-in browser and navigate to <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code></p>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" onclick="document.getElementById('addDeviceModal').style.display='none'">Cancel</button> <button class="btn btn-secondary" data-close-modal="addDeviceModal" data-i18n="common.cancel">Cancel</button>
<button class="btn btn-primary" id="pairDeviceBtn">Pair Display</button> <button class="btn btn-primary" id="pairDeviceBtn" data-i18n="add_display.pair_btn">Pair Display</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -39,9 +39,34 @@ export const api = {
}), }),
// Content // Content
getContent: () => request('/content'), getContent: (folderId) => {
if (folderId === undefined) return request('/content');
const q = folderId === null ? 'root' : encodeURIComponent(folderId);
return request(`/content?folder_id=${q}`);
},
getContentItem: (id) => request(`/content/${id}`), getContentItem: (id) => request(`/content/${id}`),
deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }), deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }),
updateContent: (id, data) => request(`/content/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
moveContent: (id, folderId) => request(`/content/${id}`, {
method: 'PUT',
body: JSON.stringify({ folder_id: folderId })
}),
// Folders
getFolders: () => request('/folders'),
createFolder: (name, parentId) => request('/folders', {
method: 'POST',
body: JSON.stringify({ name, parent_id: parentId || null })
}),
renameFolder: (id, name) => request(`/folders/${id}`, {
method: 'PUT',
body: JSON.stringify({ name })
}),
moveFolder: (id, parentId) => request(`/folders/${id}`, {
method: 'PUT',
body: JSON.stringify({ parent_id: parentId || null })
}),
deleteFolder: (id) => request(`/folders/${id}`, { method: 'DELETE' }),
uploadContent: async (file, onProgress) => { uploadContent: async (file, onProgress) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@ -103,6 +128,13 @@ export const api = {
removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }), removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }),
sendGroupCommand: (groupId, type, payload) => request(`/groups/${groupId}/command`, { method: 'POST', body: JSON.stringify({ type, payload }) }), sendGroupCommand: (groupId, type, payload) => request(`/groups/${groupId}/command`, { method: 'POST', body: JSON.stringify({ type, payload }) }),
// Video walls
getWalls: () => request('/walls'),
createWall: (data) => request('/walls', { method: 'POST', body: JSON.stringify(data) }),
setWallDevices: (id, devices) => request(`/walls/${id}/devices`, { method: 'PUT', body: JSON.stringify({ devices }) }),
updateWall: (id, data) => request(`/walls/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteWall: (id) => request(`/walls/${id}`, { method: 'DELETE' }),
// Playlists // Playlists
getPlaylists: () => request('/playlists'), getPlaylists: () => request('/playlists'),
createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }), createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }),
@ -114,14 +146,71 @@ export const api = {
updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }), updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }), deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }),
reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }), reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }),
// #74/#75 per-item schedule blocks
getItemSchedules: (id, itemId) => request(`/playlists/${id}/items/${itemId}/schedules`),
setItemSchedules: (id, itemId, blocks) => request(`/playlists/${id}/items/${itemId}/schedules`, { method: 'PUT', body: JSON.stringify({ blocks }) }),
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }), assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }),
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
// Device Groups - Playlist // Device Groups - Playlist
groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }), groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }),
// API Tokens (personal access tokens, workspace-scoped)
getTokens: () => request('/tokens'),
createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }),
revokeToken: (id) => request('/tokens/' + id, { method: 'DELETE' }),
// Current user
getMe: () => request('/auth/me'),
updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }),
renameWorkspace: (id, data) => request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
// Workspace members + invites (slice 2A read-only)
getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`),
getWorkspaceInvites: (id) => request(`/workspaces/${id}/invites`),
// Workspace member/invite mutations (slice 2B). All admin-only server-side
// (canAdminWorkspace gate). Server returns translated English error messages
// mapped to i18n keys via mapMutationError() in workspace-members.js.
inviteWorkspaceMember: (workspaceId, data) => request(`/workspaces/${workspaceId}/invites`, { method: 'POST', body: JSON.stringify(data) }),
cancelWorkspaceInvite: (workspaceId, inviteId) => request(`/workspaces/${workspaceId}/invites/${inviteId}`, { method: 'DELETE' }),
updateWorkspaceMemberRole: (workspaceId, userId, role) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
removeWorkspaceMember: (workspaceId, userId) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'DELETE' }),
// Slice 2C - accept a workspace invite by id (post-auth flow)
acceptInvite: (inviteId) => request(`/auth/accept-invite/${inviteId}`, { method: 'POST' }),
// Admin-provisioned user creation (#10). data: { email, name, password,
// workspaceId, role, mustChangePassword }
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
adminCreateOrg: (name) => request('/admin/orgs', { method: 'POST', body: JSON.stringify({ name }) }),
adminListOrgs: () => request('/admin/orgs'),
adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }),
adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }),
aiGetSettings: () => request('/ai/settings'),
aiSaveSettings: (data) => request('/ai/settings', { method: 'PUT', body: JSON.stringify(data) }),
aiGenerateDesign: (prompt) => request('/ai/generate-design', { method: 'POST', body: JSON.stringify({ prompt }) }),
aiListModels: (base_url, api_key) => request('/ai/models', { method: 'POST', body: JSON.stringify({ base_url, api_key }) }),
// Instance-level default branding (#15, platform admin).
adminGetBranding: () => request('/admin/branding'),
adminSetBranding: (data) => request('/admin/branding', { method: 'PUT', body: JSON.stringify(data) }),
// Per-user workspace membership management (platform Users page modal).
adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`),
adminAddUserWorkspace: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces`, { method: 'POST', body: JSON.stringify({ workspaceId, role }) }),
adminSetUserWorkspaceRole: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
adminRemoveUserWorkspace: (id, workspaceId) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'DELETE' }),
// Admin - Users // Admin - Users
getUsers: () => request('/auth/users'), getUsers: () => request('/auth/users'),
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }), deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
resetUserPassword: (id, password) => request(`/auth/users/${id}/password`, {
method: 'PUT',
body: JSON.stringify({ password }),
}),
assignPlan: (user_id, plan_id) => request('/subscription/assign', { assignPlan: (user_id, plan_id) => request('/subscription/assign', {
method: 'POST', method: 'POST',
body: JSON.stringify({ user_id, plan_id }) body: JSON.stringify({ user_id, plan_id })

View file

@ -16,13 +16,192 @@ import * as onboarding from './views/onboarding.js';
import * as help from './views/help.js'; import * as help from './views/help.js';
import * as teams from './views/teams.js'; import * as teams from './views/teams.js';
import * as admin from './views/admin.js'; import * as admin from './views/admin.js';
import * as adminPlayerDebug from './views/admin-player-debug.js';
import * as designer from './views/designer.js'; import * as designer from './views/designer.js';
import * as playlists from './views/playlists.js'; import * as playlists from './views/playlists.js';
import * as workspaceMembers from './views/workspace-members.js';
import * as forcePasswordChange from './views/force-password-change.js';
import * as noWorkspace from './views/no-workspace.js';
import { applyBranding } from './branding.js';
import { t } from './i18n.js';
import { isPlatformAdmin } from './utils.js';
import { renderWorkspaceSwitcher } from './components/workspace-switcher.js';
import { showToast } from './components/toast.js';
import { api } from './api.js';
const app = document.getElementById('app'); const app = document.getElementById('app');
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
let currentView = null; let currentView = null;
// ==================== Slice 2C: accept-invite plumbing ====================
//
// Flow shape (covers all six auth entry points - login, register, support,
// Google, Microsoft, first-user-setup - because they all funnel through
// onAuthSuccess() in login.js which calls window.location.reload()):
//
// 1. Hash route #/accept-invite/{id}:
// - unauthed: stash inviteId in localStorage, redirect to login
// - authed: call consumeAcceptInvite() directly (no stash)
// 2. App boot (every route() call once auth checks pass): if a valid
// non-stale stash is present, fire consumeAcceptInvite. After login
// reload lands here and picks it up automatically.
// 3. consumeAcceptInvite on success: stash toast text, switch workspace,
// reload. Reload re-fires route() which picks up the toast stash and
// shows it on dashboard. Reload is needed for the new JWT/socket/
// sidebar /me to pick up the new workspace context.
// 4. consumeAcceptInvite on error: showToast directly + clear stash.
// No reload (no state change to propagate).
const PENDING_INVITE_KEY = 'pending_invite';
const PENDING_INVITE_TOAST_KEY = 'pending_invite_toast';
// Mirrors the backend INVITE_EXPIRY_DAYS default (7). If an operator changes
// the backend default, this should be updated to match - tracked in handoff.
const INVITE_EXPIRY_DAYS_FRONTEND = 7;
// Non-reentrant guard: route() can fire multiple times (hashchange events).
// Once consume is in flight, additional calls no-op until reload completes.
let _acceptInFlight = false;
function stashPendingInvite(inviteId) {
localStorage.setItem(PENDING_INVITE_KEY, JSON.stringify({
inviteId,
stashedAt: Math.floor(Date.now() / 1000),
}));
}
function readPendingInvite() {
const raw = localStorage.getItem(PENDING_INVITE_KEY);
if (!raw) return null;
let parsed;
try { parsed = JSON.parse(raw); }
catch { localStorage.removeItem(PENDING_INVITE_KEY); return null; }
if (!parsed?.inviteId || !parsed?.stashedAt) {
localStorage.removeItem(PENDING_INVITE_KEY);
return null;
}
const ageSecs = Math.floor(Date.now() / 1000) - parsed.stashedAt;
if (ageSecs > INVITE_EXPIRY_DAYS_FRONTEND * 86400) {
localStorage.removeItem(PENDING_INVITE_KEY);
return null;
}
return parsed.inviteId;
}
function clearPendingInvite() {
localStorage.removeItem(PENDING_INVITE_KEY);
}
// Map backend error message text to a translated toast string. We match
// English text because api.js doesn't surface HTTP status codes today;
// refactor to err.status when that lands - tracked in handoff doc.
function mapAcceptError(err) {
const msg = err?.message || '';
if (/Invite not found/i.test(msg)) return t('accept.error.not_found');
if (/Invite has expired|Workspace no longer exists/i.test(msg)) return t('accept.error.expired');
if (/different email address/i.test(msg)) return t('accept.error.wrong_account');
return t('accept.error.generic');
}
async function consumeAcceptInvite(inviteId) {
if (_acceptInFlight) return;
_acceptInFlight = true;
try {
const result = await api.acceptInvite(inviteId);
// Switch to the joined workspace. New JWT carries the workspace context;
// reload picks it up for sidebar /me + socket rooms + data fetches. If
// the switch fails, log and reload anyway - the membership was created
// so the user can switch manually via the dropdown.
try {
const sw = await api.switchWorkspace(result.workspace_id);
if (sw?.token) localStorage.setItem('token', sw.token);
} catch (e) {
console.warn('switchWorkspace after accept failed (non-fatal):', e.message);
}
// Stash the toast text in a scoped key (not a generic pending-toast
// channel) so app boot below fires it after reload.
const toastKey = result.already_member ? 'accept.already_member' : 'accept.success';
localStorage.setItem(PENDING_INVITE_TOAST_KEY, JSON.stringify({
message: t(toastKey, { name: result.workspace_name }),
kind: 'success',
}));
clearPendingInvite();
// history.replaceState mutates the hash WITHOUT firing hashchange.
// Important: a plain `location.hash = '#/'` would fire hashchange
// synchronously, causing route() to fire a second time before the
// reload runs - that second route() call would consume the toast key
// and attach the toast to a DOM that's about to be destroyed by the
// reload. Using replaceState bypasses that race so the post-reload
// route() is the only one that picks up the toast.
history.replaceState(null, '', window.location.pathname + '#/');
window.location.reload();
} catch (err) {
showToast(mapAcceptError(err), 'error');
clearPendingInvite();
_acceptInFlight = false;
}
}
// Fires once per page load (single-shot key in localStorage). If the
// previous routeApp cycle stashed a toast across reload, show it now.
function consumePendingInviteToast() {
const raw = localStorage.getItem(PENDING_INVITE_TOAST_KEY);
if (!raw) return;
localStorage.removeItem(PENDING_INVITE_TOAST_KEY);
try {
const { message, kind } = JSON.parse(raw);
if (message) showToast(message, kind || 'info');
} catch {}
}
// Map nav-link data-view to its translation key.
const NAV_LABEL_KEYS = {
dashboard: 'nav.displays',
content: 'nav.content',
playlists: 'nav.playlists',
layouts: 'nav.layouts',
widgets: 'nav.widgets',
schedule: 'nav.schedule',
walls: 'nav.walls',
reports: 'nav.reports',
kiosk: 'nav.kiosk',
designer: 'nav.designer',
activity: 'nav.activity',
teams: 'nav.teams',
help: 'nav.help',
settings: 'nav.settings',
billing: 'nav.subscription',
admin: 'nav.admin',
};
function renderNavLabels() {
document.querySelectorAll('.nav-link').forEach((link) => {
const key = NAV_LABEL_KEYS[link.dataset.view];
if (!key) return;
const span = link.querySelector('span');
if (span) span.textContent = t(key);
});
}
// Translate any element marked with data-i18n / data-i18n-placeholder /
// data-i18n-html. Runs on init and on every language change. Used for static
// HTML in index.html (e.g. the Add-Display modal) where t() can't be inlined
// at template time.
function translateStaticDom(root = document) {
root.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
root.querySelectorAll('[data-i18n-html]').forEach((el) => {
el.innerHTML = t(el.getAttribute('data-i18n-html'));
});
root.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
});
}
function isAuthenticated() { function isAuthenticated() {
return !!localStorage.getItem('token'); return !!localStorage.getItem('token');
} }
@ -33,12 +212,69 @@ function getCurrentUser() {
} catch { return null; } } catch { return null; }
} }
// #12: true when a signed-in user provably has zero accessible workspaces and
// no platform-level reach. Requires accessible_workspaces to be present (only
// /me populates it) - undefined means "not loaded yet", so we DON'T trigger and
// fall through to the normal (workspace-empty-safe) views until /me resolves.
function hasNoAccessibleWorkspace(u) {
return !!u
&& Array.isArray(u.accessible_workspaces)
&& u.accessible_workspaces.length === 0
&& !u.current_workspace_id
&& !isPlatformAdmin(u);
}
// Refresh the cached user from the server. The server reads plan_id fresh
// from the DB on every request, but the frontend only wrote `user` into
// localStorage at login — so plan/role changes made by an admin weren't
// visible until the user logged out and back in.
async function refreshCurrentUser() {
const token = localStorage.getItem('token');
if (!token) return;
try {
const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) return;
const fresh = await res.json();
localStorage.setItem('user', JSON.stringify(fresh));
// Re-render the workspace switcher on every /me refresh - cheap, and keeps
// the dropdown in sync if a workspace was added/removed in another tab.
renderWorkspaceSwitcher(fresh);
window.dispatchEvent(new CustomEvent('user-refreshed', { detail: fresh }));
// #12: /me is the first place accessible_workspaces is known. If it resolves
// to zero (org-less user), send them to the empty state now - on a fresh
// load route() may have already rendered the dashboard before /me returned.
// Guard against the login / change-password / already-there screens to avoid
// a redirect loop.
const hash = window.location.hash || '#/';
if (hasNoAccessibleWorkspace(fresh)
&& hash !== '#/no-workspace' && hash !== '#/login' && hash !== '#/change-password') {
window.location.hash = '#/no-workspace';
}
} catch {}
}
function route() { function route() {
// Cleanup previous view // Cleanup previous view
if (currentView && currentView.cleanup) currentView.cleanup(); if (currentView && currentView.cleanup) currentView.cleanup();
const hash = window.location.hash || '#/'; const hash = window.location.hash || '#/';
// Slice 2C - direct hits on #/accept-invite/{id}. Handle BEFORE the
// auth-redirect-to-login because an unauthed visit needs to stash the
// inviteId so it survives the redirect.
if (hash.startsWith('#/accept-invite/')) {
const inviteId = hash.split('#/accept-invite/')[1].split('/')[0];
if (inviteId) {
if (!isAuthenticated()) {
stashPendingInvite(inviteId);
window.location.hash = '#/login';
return;
}
consumeAcceptInvite(inviteId); // helper handles routing (reload to '#/')
return;
}
}
// Auth check - redirect to login if not authenticated // Auth check - redirect to login if not authenticated
if (!isAuthenticated() && hash !== '#/login') { if (!isAuthenticated() && hash !== '#/login') {
window.location.hash = '#/login'; window.location.hash = '#/login';
@ -51,6 +287,69 @@ function route() {
return; return;
} }
// Slice 2C - past the auth gates. (a) Show any toast stashed across the
// accept-invite reload boundary. (b) If a stash exists (from an unauthed
// accept-invite visit + subsequent login/register), consume it now. The
// helper's in-flight guard prevents double-fire on subsequent hashchanges.
if (isAuthenticated()) {
consumePendingInviteToast();
const stashedInviteId = readPendingInvite();
if (stashedInviteId) {
consumeAcceptInvite(stashedInviteId);
return;
}
}
// #10: forced first-login password change. An admin-provisioned user carries
// must_change_password until they set their own password. Block every other
// authenticated view and force them to the change-password screen; the server
// clears the flag on a successful PUT /api/auth/me. The screen itself is the
// one exception (so they can actually change it).
if (isAuthenticated()) {
const u = getCurrentUser();
if (u && u.must_change_password && hash !== '#/change-password') {
window.location.hash = '#/change-password';
return;
}
if (hash === '#/change-password') {
if (!u || !u.must_change_password) {
// Not (or no longer) required - don't strand the user on a dead screen.
window.location.hash = '#/';
return;
}
sidebar.style.display = 'none';
app.style.marginLeft = '0';
const mb = document.getElementById('mobileMenuBtn');
if (mb) mb.style.display = 'none';
currentView = forcePasswordChange;
forcePasswordChange.render(app);
return;
}
}
// #12: a signed-in user with zero accessible workspaces (org-less self-signup
// on an AUTO_CREATE_ORG_ON_SIGNUP=false deployment) lands on a "no workspaces
// yet" empty state instead of being bounced into onboarding (whose pairing
// step needs a workspace). Only fires once /me has populated
// accessible_workspaces; until then the workspace-empty-safe dashboard shows.
if (isAuthenticated()) {
const u = getCurrentUser();
if (hasNoAccessibleWorkspace(u) && hash !== '#/no-workspace') {
window.location.hash = '#/no-workspace';
return;
}
if (hash === '#/no-workspace') {
if (!hasNoAccessibleWorkspace(u)) { window.location.hash = '#/'; return; }
sidebar.style.display = 'none';
app.style.marginLeft = '0';
const mb = document.getElementById('mobileMenuBtn');
if (mb) mb.style.display = 'none';
currentView = noWorkspace;
noWorkspace.render(app);
return;
}
}
// Onboarding for new users // Onboarding for new users
if (hash === '#/onboarding' && isAuthenticated()) { if (hash === '#/onboarding' && isAuthenticated()) {
sidebar.style.display = 'none'; sidebar.style.display = 'none';
@ -64,6 +363,8 @@ function route() {
if (hash === '#/login') { if (hash === '#/login') {
sidebar.style.display = 'none'; sidebar.style.display = 'none';
app.style.marginLeft = '0'; app.style.marginLeft = '0';
const mb = document.getElementById('mobileMenuBtn');
if (mb) mb.style.display = 'none';
currentView = login; currentView = login;
login.render(app); login.render(app);
return; return;
@ -72,6 +373,8 @@ function route() {
// Show sidebar for authenticated views // Show sidebar for authenticated views
sidebar.style.display = ''; sidebar.style.display = '';
app.style.marginLeft = ''; app.style.marginLeft = '';
const mb = document.getElementById('mobileMenuBtn');
if (mb) mb.style.display = '';
// Update user info in sidebar // Update user info in sidebar
updateSidebarUser(); updateSidebarUser();
@ -137,9 +440,17 @@ function route() {
} else if (hash === '#/teams' || hash.startsWith('#/team/')) { } else if (hash === '#/teams' || hash.startsWith('#/team/')) {
currentView = teams; currentView = teams;
teams.render(app); teams.render(app);
} else if (hash.startsWith('#/workspace/') && hash.includes('/members')) {
const wsId = hash.split('#/workspace/')[1].split('/')[0];
currentView = workspaceMembers;
workspaceMembers.render(app, wsId);
} else if (hash === '#/help' || hash.startsWith('#/help')) { } else if (hash === '#/help' || hash.startsWith('#/help')) {
currentView = help; currentView = help;
help.render(app); help.render(app);
} else if (hash.startsWith('#/admin/player-debug')) {
// Match prefix so query params (?page=2&ua=Tizen) route correctly.
currentView = adminPlayerDebug;
adminPlayerDebug.render(app);
} else if (hash === '#/admin') { } else if (hash === '#/admin') {
currentView = admin; currentView = admin;
admin.render(app); admin.render(app);
@ -159,9 +470,9 @@ function updateSidebarUser() {
const user = getCurrentUser(); const user = getCurrentUser();
if (!user) return; if (!user) return;
// Show admin nav only for superadmins // Show admin nav only for platform admins (legacy 'superadmin' or Phase 1 renamed 'platform_admin')
const adminNav = document.getElementById('adminNavItem'); const adminNav = document.getElementById('adminNavItem');
if (adminNav) adminNav.style.display = user.role === 'superadmin' ? '' : 'none'; if (adminNav) adminNav.style.display = isPlatformAdmin(user) ? '' : 'none';
let userEl = document.getElementById('sidebarUser'); let userEl = document.getElementById('sidebarUser');
if (!userEl) { if (!userEl) {
@ -179,7 +490,7 @@ function updateSidebarUser() {
<div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${user.name || user.email}</div> <div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${user.name || user.email}</div>
<div style="font-size:10px;color:var(--text-muted)">${user.role}</div> <div style="font-size:10px;color:var(--text-muted)">${user.role}</div>
</div> </div>
<button id="logoutBtn" class="btn-icon" title="Sign out" style="flex-shrink:0"> <button id="logoutBtn" class="btn-icon" title="${t('auth.sign_out')}" style="flex-shrink:0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/> <polyline points="16 17 21 12 16 7"/>
@ -197,19 +508,47 @@ function updateSidebarUser() {
} }
// Initialize // Initialize
renderNavLabels();
translateStaticDom();
window.addEventListener('language-changed', () => {
renderNavLabels();
translateStaticDom();
});
if (isAuthenticated()) { if (isAuthenticated()) {
connectSocket(); connectSocket();
applyBranding();
refreshCurrentUser().then(() => updateSidebarUser());
} }
// Refresh the cached user on every route transition so plan/role changes
// made by an admin propagate without requiring a re-login.
window.addEventListener('hashchange', () => { if (isAuthenticated()) refreshCurrentUser(); });
// Register PWA service worker // Register PWA service worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw-admin.js').catch(() => {}); navigator.serviceWorker.register('/sw-admin.js').catch(() => {});
} }
// Close mobile menu on navigation // Mobile sidebar: open/close via hamburger, backdrop, nav tap, Escape
window.addEventListener('hashchange', () => { const sidebarEl = document.querySelector('.sidebar');
document.querySelector('.sidebar')?.classList.remove('open'); const backdropEl = document.getElementById('sidebarBackdrop');
document.getElementById('sidebarBackdrop')?.classList.remove('open'); const menuBtn = document.getElementById('mobileMenuBtn');
function setMobileNav(open) {
if (!sidebarEl || !backdropEl) return;
sidebarEl.classList.toggle('open', open);
backdropEl.classList.toggle('open', open);
menuBtn?.setAttribute('aria-expanded', open ? 'true' : 'false');
}
menuBtn?.addEventListener('click', () => {
setMobileNav(!sidebarEl.classList.contains('open'));
});
backdropEl?.addEventListener('click', () => setMobileNav(false));
window.addEventListener('hashchange', () => setMobileNav(false));
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && sidebarEl?.classList.contains('open')) setMobileNav(false);
}); });
// Auto-reload on frontend update (no more hard refresh needed) // Auto-reload on frontend update (no more hard refresh needed)
@ -262,3 +601,12 @@ if (isAuthenticated()) {
} }
window.addEventListener('hashchange', route); window.addEventListener('hashchange', route);
route(); route();
// Close-modal buttons (replaces inline onclick handlers — required for CSP).
document.addEventListener('click', (e) => {
const closer = e.target.closest('[data-close-modal]');
if (!closer) return;
const id = closer.dataset.closeModal;
const modal = document.getElementById(id);
if (modal) modal.style.display = 'none';
});

View file

@ -0,0 +1,54 @@
// Render-blocking branding primer (#38). Loaded as a synchronous same-origin
// <script> right after the sidebar logo, so it runs DURING parse, before first
// paint — applying the current workspace's CACHED white-label so the page paints
// branded instead of flashing the "ScreenTinker" default. branding.js then
// refreshes it from the server and re-writes the cache. Plain script (not a
// module) so it's not deferred; keyed by workspace so a switch shows the right
// brand (or the neutral default for a workspace we haven't cached yet).
(function () {
try {
var token = localStorage.getItem('token');
if (!token) return;
var ws = 'none';
try {
var seg = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
ws = (JSON.parse(atob(seg)) || {}).current_workspace_id || 'none';
} catch (e) { /* malformed token -> treat as no workspace */ }
var wl = JSON.parse(localStorage.getItem('rd_branding_' + ws) || 'null');
if (!wl) {
// #76: no per-workspace cache yet (e.g. a never-visited org). Fall back to
// the server-injected instance / custom-domain branding so the page paints
// the configured brand instead of flashing the ScreenTinker default;
// branding.js then fetches and caches the workspace-specific brand.
try {
var ssr = document.querySelector('meta[name="ssr-brand"]');
if (ssr && ssr.content) wl = JSON.parse(ssr.content);
} catch (e) { /* ignore */ }
}
if (!wl) return;
var root = document.documentElement;
if (wl.primary_color) root.style.setProperty('--accent', wl.primary_color);
if (wl.bg_color) {
root.style.setProperty('--bg-primary', wl.bg_color);
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', wl.bg_color);
}
if (wl.brand_name) {
document.title = wl.brand_name;
var span = document.getElementById('brandName');
if (span) span.textContent = wl.brand_name;
}
if (wl.favicon_url) {
var links = document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]');
for (var i = 0; i < links.length; i++) links[i].setAttribute('href', wl.favicon_url);
}
if (wl.custom_css) {
var s = document.createElement('style');
s.id = 'wl-custom-css';
s.textContent = wl.custom_css;
document.head.appendChild(s);
}
} catch (e) { /* never let branding break boot */ }
})();

71
frontend/js/branding.js Normal file
View file

@ -0,0 +1,71 @@
// Applies the current user's saved white-label config to the DOM.
// Runs once after login/route bootstrap. Without this, saved values in the
// white_labels table are read into the Settings form but never applied to
// the actual page — so users see "ScreenTinker" and default colors after
// every reload, as if their save reverted.
let applied = false;
// Current workspace id from the JWT, so the branding cache (read render-blocking by
// brand-prime.js) is keyed per workspace — a switch shows the right brand. (#38)
function currentWorkspaceId() {
try {
const seg = localStorage.getItem('token').split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return (JSON.parse(atob(seg)) || {}).current_workspace_id || 'none';
} catch { return 'none'; }
}
export async function applyBranding() {
if (applied) return;
applied = true;
const token = localStorage.getItem('token');
if (!token) return;
let wl;
try {
const res = await fetch('/api/white-label', { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) return;
wl = await res.json();
} catch { return; }
if (!wl) return;
// Cache for the next load/switch so brand-prime.js can apply it before paint.
try { localStorage.setItem('rd_branding_' + currentWorkspaceId(), JSON.stringify(wl)); } catch {}
const root = document.documentElement;
if (wl.primary_color) root.style.setProperty('--accent', wl.primary_color);
if (wl.bg_color) {
root.style.setProperty('--bg-primary', wl.bg_color);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', wl.bg_color);
}
if (wl.brand_name) {
document.title = wl.brand_name;
const span = document.getElementById('brandName');
if (span) span.textContent = wl.brand_name;
}
if (wl.favicon_url) {
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(l => {
l.setAttribute('href', wl.favicon_url);
});
}
if (wl.custom_css) {
let style = document.getElementById('wl-custom-css');
if (!style) {
style = document.createElement('style');
style.id = 'wl-custom-css';
document.head.appendChild(style);
}
style.textContent = wl.custom_css;
}
}
// Force a re-apply (called from settings.js after save)
export function resetBranding() {
applied = false;
return applyBranding();
}

View file

@ -0,0 +1,72 @@
import { api } from '../api.js';
import { t } from '../i18n.js';
// Create-Organization modal (#35). Platform-admin only (the page is gated; the
// endpoint re-checks). Creates a named org + its first "Default" workspace, owned
// by the creating admin (organizations.owner_user_id is NOT NULL). On success the
// org appears in the switcher, so we reload to refresh it — matching the
// workspace rename/switch flow. opts.onSuccess(result) fires before reload.
export function openCreateOrgModal(opts = {}) {
const { onSuccess } = opts;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<h3>${t('admin.create_org.title')}</h3>
<button class="btn-icon" type="button" data-org-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="createOrgName">${t('admin.create_org.name')}</label>
<input id="createOrgName" type="text" class="input" maxlength="120" placeholder="${t('admin.create_org.placeholder')}" style="width:100%">
<div style="color:var(--text-muted);font-size:11px;margin-top:4px">${t('admin.create_org.hint')}</div>
</div>
<div id="createOrgError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-org-close>${t('common.cancel')}</button>
<button class="btn btn-primary" type="button" id="createOrgSave">${t('admin.create_org.submit')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const nameInput = overlay.querySelector('#createOrgName');
const errorEl = overlay.querySelector('#createOrgError');
const saveBtn = overlay.querySelector('#createOrgSave');
nameInput.focus();
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
function onKey(e) {
if (e.key === 'Escape') close();
else if (e.key === 'Enter' && e.target === nameInput) save();
}
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelectorAll('[data-org-close]').forEach(b => b.addEventListener('click', close));
async function save() {
errorEl.style.display = 'none';
const name = nameInput.value.trim();
if (!name) { showError(t('admin.create_org.err_empty')); return; }
saveBtn.disabled = true;
saveBtn.textContent = t('common.saving');
try {
const result = await api.adminCreateOrg(name);
if (typeof onSuccess === 'function') onSuccess(result);
window.location.reload();
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = t('admin.create_org.submit');
showError(err.message || t('admin.create_org.err_failed'));
}
}
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
saveBtn.addEventListener('click', save);
}

View file

@ -0,0 +1,163 @@
// "Manage workspaces" modal for the platform Users admin page. Lets a platform
// admin see/manage ALL of a user's workspace memberships: list each with an
// inline role dropdown + Remove, and add the user to more workspaces via a
// type-to-filter picker. Backed by /api/admin/users/:id/workspaces.
import { api } from '../api.js';
import { t } from '../i18n.js';
import { showToast } from '../components/toast.js';
// Display order = least-privilege first (the default for the add row). The SET
// must match the server's accepted WORKSPACE_ROLES (routes/admin.js).
const WORKSPACE_ROLES = ['workspace_viewer', 'workspace_editor', 'workspace_admin'];
const STAFF_ROLES = ['platform_admin', 'superadmin', 'platform_operator'];
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function roleOptions(selected) {
return WORKSPACE_ROLES.map(r => `<option value="${r}"${r === selected ? ' selected' : ''}>${esc(t('members.role.' + r))}</option>`).join('');
}
const wsLabel = w => `${w.organization_name || '—'} / ${w.name}`;
// user: { id, name, email, role }; opts.onClose fires (once) if anything changed.
export function openManageWorkspacesModal(user, opts = {}) {
const { onClose } = opts;
const isStaff = STAFF_ROLES.includes(user.role);
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<h3>${t('manage_ws.title', { user: esc(user.name || user.email) })}</h3>
<button class="btn-icon" type="button" data-mws-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
${isStaff ? `<p style="font-size:12px;color:var(--text-muted);background:var(--bg-input);padding:8px 10px;border-radius:6px;margin-bottom:12px">${t('manage_ws.staff_note')}</p>` : ''}
<h4 style="font-size:14px;margin:0 0 8px">${t('manage_ws.current')}</h4>
<div id="mwsList" style="color:var(--text-muted);font-size:13px">${t('common.loading')}</div>
<h4 style="font-size:14px;margin:16px 0 8px">${t('manage_ws.add')}</h4>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input id="mwsFilter" type="text" class="input" placeholder="${t('manage_ws.filter')}" style="flex:1;min-width:150px" autocomplete="off">
<select id="mwsAddWs" class="input" style="flex:2;min-width:170px"></select>
<select id="mwsAddRole" class="input" style="width:auto">${roleOptions('workspace_viewer')}</select>
<button class="btn btn-secondary" type="button" id="mwsAddBtn">${t('manage_ws.add_btn')}</button>
</div>
<div id="mwsError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="button" data-mws-close>${t('manage_ws.done')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const listEl = overlay.querySelector('#mwsList');
const filterEl = overlay.querySelector('#mwsFilter');
const addWsEl = overlay.querySelector('#mwsAddWs');
const addRoleEl = overlay.querySelector('#mwsAddRole');
const addBtn = overlay.querySelector('#mwsAddBtn');
const errorEl = overlay.querySelector('#mwsError');
let allWs = []; // assignable workspaces (from /me)
let memberships = []; // current memberships
let changed = false; // refresh the table on close only if something changed
function close() {
overlay.remove();
document.removeEventListener('keydown', onKey);
if (changed && typeof onClose === 'function') { try { onClose(); } catch (e) { console.error(e); } }
}
function onKey(e) { if (e.key === 'Escape') close(); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
overlay.querySelectorAll('[data-mws-close]').forEach(b => b.addEventListener('click', close));
const showError = m => { errorEl.textContent = m; errorEl.style.display = 'block'; };
const clearError = () => { errorEl.style.display = 'none'; };
function renderAddOptions() {
const memberIds = new Set(memberships.map(m => m.workspace_id));
const f = (filterEl.value || '').trim().toLowerCase();
const avail = allWs.filter(w => !memberIds.has(w.id) && (!f || wsLabel(w).toLowerCase().includes(f)));
let html = `<option value="">${esc(t('manage_ws.pick'))}</option>`;
let curOrg = null;
for (const w of avail) {
const org = w.organization_name || '—';
if (org !== curOrg) { if (curOrg !== null) html += '</optgroup>'; html += `<optgroup label="${esc(org)}">`; curOrg = org; }
html += `<option value="${esc(w.id)}">${esc(w.name)}</option>`;
}
if (curOrg !== null) html += '</optgroup>';
addWsEl.innerHTML = html;
}
function renderList() {
if (!memberships.length) {
listEl.innerHTML = `<p style="color:var(--text-muted);font-size:13px">${t('manage_ws.empty')}</p>`;
return;
}
listEl.innerHTML = memberships.map(m => `
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">
<div style="flex:1;min-width:0">
<div style="font-weight:500">${esc(m.workspace_name)}</div>
<div style="font-size:11px;color:var(--text-muted)">${esc(m.organization_name || '')}</div>
</div>
<select class="input" style="width:auto;font-size:12px;padding:4px;background:var(--bg-input)" data-mws-role="${esc(m.workspace_id)}">${roleOptions(m.role)}</select>
<button class="btn btn-danger btn-sm" type="button" data-mws-remove="${esc(m.workspace_id)}">${t('manage_ws.remove')}</button>
</div>
`).join('');
listEl.querySelectorAll('[data-mws-role]').forEach(sel => {
sel.onchange = async () => {
clearError();
try { await api.adminSetUserWorkspaceRole(user.id, sel.dataset.mwsRole, sel.value); changed = true; showToast(t('manage_ws.toast.role'), 'success'); await reload(); }
catch (e) { showError(e.message); await reload(); }
};
});
listEl.querySelectorAll('[data-mws-remove]').forEach(btn => {
btn.onclick = async () => {
clearError();
try { await api.adminRemoveUserWorkspace(user.id, btn.dataset.mwsRemove); changed = true; showToast(t('manage_ws.toast.removed'), 'success'); await reload(); }
catch (e) { showError(e.message); await reload(); }
};
});
}
async function reload() {
memberships = await api.adminGetUserWorkspaces(user.id).catch(() => memberships);
renderList();
renderAddOptions();
}
filterEl.addEventListener('input', renderAddOptions);
addBtn.addEventListener('click', async () => {
clearError();
const wsId = addWsEl.value;
const role = addRoleEl.value;
if (!wsId) { showError(t('manage_ws.pick_required')); return; }
addBtn.disabled = true;
try {
await api.adminAddUserWorkspace(user.id, wsId, role);
changed = true;
showToast(t('manage_ws.toast.added'), 'success');
filterEl.value = '';
await reload();
} catch (e) { showError(e.message); }
finally { addBtn.disabled = false; }
});
// initial load
(async () => {
try {
const [mem, me] = await Promise.all([api.adminGetUserWorkspaces(user.id), api.getMe().catch(() => ({}))]);
memberships = Array.isArray(mem) ? mem : [];
allWs = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces.slice() : [];
renderList();
renderAddOptions();
} catch (e) {
listEl.innerHTML = `<p style="color:var(--danger);font-size:13px">${esc(e.message || 'Failed to load')}</p>`;
}
})();
}

View file

@ -2,6 +2,8 @@ export function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer'); const container = document.getElementById('toastContainer');
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `toast ${type}`; toast.className = `toast ${type}`;
toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
toast.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
toast.innerHTML = ` toast.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
${type === 'success' ? '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>' : ${type === 'success' ? '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>' :

View file

@ -0,0 +1,75 @@
import { t } from '../i18n.js';
// Reusable destructive-confirmation modal (#36). The primary (danger) button stays
// disabled until the user types `expected` exactly — guards irreversible deletes
// (delete org / workspace). opts:
// title, body (HTML allowed - caller escapes), expected (string to type),
// confirmLabel, onConfirm: async () => any (throw to show an inline error)
export function openTypeToConfirmModal(opts = {}) {
const { title, body = '', expected, confirmLabel, onConfirm } = opts;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<h3>${esc(title || '')}</h3>
<button class="btn-icon" type="button" data-ttc-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div style="font-size:13px;line-height:1.5;margin-bottom:12px">${body}</div>
<div class="form-group">
<label for="ttcInput">${t('confirm_delete.type_label', { name: esc(expected) })}</label>
<input id="ttcInput" type="text" class="input" autocomplete="off" style="width:100%">
</div>
<div id="ttcError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-ttc-close>${t('common.cancel')}</button>
<button class="btn btn-danger" type="button" id="ttcConfirm" disabled>${esc(confirmLabel || t('common.delete'))}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#ttcInput');
const confirmBtn = overlay.querySelector('#ttcConfirm');
const errorEl = overlay.querySelector('#ttcError');
input.focus();
const matches = () => input.value.trim() === String(expected);
input.addEventListener('input', () => { confirmBtn.disabled = !matches(); });
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
function onKey(e) {
if (e.key === 'Escape') close();
else if (e.key === 'Enter' && matches()) confirm();
}
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelectorAll('[data-ttc-close]').forEach(b => b.addEventListener('click', close));
async function confirm() {
if (!matches()) return;
errorEl.style.display = 'none';
confirmBtn.disabled = true;
confirmBtn.textContent = t('common.deleting');
try {
await onConfirm?.();
close();
} catch (err) {
confirmBtn.disabled = false;
confirmBtn.textContent = confirmLabel || t('common.delete');
errorEl.textContent = err?.message || t('confirm_delete.failed');
errorEl.style.display = 'block';
}
}
confirmBtn.addEventListener('click', confirm);
}
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

View file

@ -0,0 +1,193 @@
// Add-User modal (#10). Creates a user account directly with an admin-set
// password and assigns them to a workspace + role (admin-provisioning for
// instances with no outbound email). Two open modes, ONE shared form:
//
// openAddUserModal({ id, name }, opts) -> fixed-workspace mode (members view).
// No picker; assigns into that workspace.
// openAddUserModal(null, opts) -> picker mode (platform Users admin page).
// Shows an Org/Workspace picker; the admin
// chooses the target workspace.
//
// opts.onSuccess: (result) => void - fires on 201 (server response body)
// opts.mapError: (err) => string - translates server error to display text
import { api } from '../api.js';
import { t } from '../i18n.js';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Roles the picker offers. This is the SET POST /api/admin/users accepts
// (server: routes/admin.js WORKSPACE_ROLES) - keep them in sync so we never
// offer a value the endpoint 400s (the platform_operator dropdown/endpoint
// mismatch we already hit). Order here is display order (least-privilege first
// = the default selection); the server validates set membership, not order.
const WORKSPACE_ROLES = ['workspace_viewer', 'workspace_editor', 'workspace_admin'];
// Crockford-ish readable random password: avoids ambiguous chars (0/O, 1/l/I).
function generatePassword(len = 16) {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
const arr = new Uint32Array(len);
crypto.getRandomValues(arr);
let out = '';
for (let i = 0; i < len; i++) out += alphabet[arr[i] % alphabet.length];
return out;
}
function wsLabel(w) {
return `${w.organization_name || '—'} / ${w.name}`;
}
export function openAddUserModal(workspace, opts = {}) {
const { onSuccess, mapError } = opts;
// Picker mode whenever no concrete target workspace was supplied.
const pickerMode = !(workspace && workspace.id);
const title = pickerMode
? t('members.modal.add_user_title_generic')
: t('members.modal.add_user_title', { workspace: esc(workspace.name) });
const roleOptions = WORKSPACE_ROLES
.map(r => `<option value="${r}">${esc(t('members.role.' + r))}</option>`)
.join('');
// Workspace picker block — only rendered in picker mode. A filter input above
// a <select> gives type-to-filter for the 70+ workspaces without a dependency.
const workspaceGroup = pickerMode ? `
<div class="form-group">
<label for="addUserWs">${t('members.modal.workspace_label')}</label>
<input id="addUserWsFilter" type="text" class="input" placeholder="${t('members.modal.workspace_filter_placeholder')}" style="width:100%;margin-bottom:6px" autocomplete="off" autocapitalize="off" spellcheck="false">
<select id="addUserWs" class="input" style="width:100%">
<option value="">${t('members.modal.workspace_loading')}</option>
</select>
</div>` : '';
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<h3>${title}</h3>
<button class="btn-icon" type="button" data-add-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="addUserEmail">${t('members.modal.email_label')}</label>
<input id="addUserEmail" type="email" class="input" placeholder="${t('members.modal.email_placeholder')}" style="width:100%" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-group">
<label for="addUserName">${t('members.modal.name_label')}</label>
<input id="addUserName" type="text" class="input" placeholder="${t('members.modal.name_placeholder')}" style="width:100%" autocomplete="off">
</div>
<div class="form-group">
<label for="addUserPassword">${t('members.modal.password_label')}</label>
<div style="display:flex;gap:8px">
<input id="addUserPassword" type="text" class="input" placeholder="${t('members.modal.password_placeholder')}" style="flex:1" autocomplete="off" autocapitalize="off" spellcheck="false">
<button class="btn btn-secondary" type="button" id="addUserGenerate" style="white-space:nowrap">${t('members.modal.generate')}</button>
</div>
</div>
${workspaceGroup}
<div class="form-group">
<label for="addUserRole">${t('members.modal.role_label')}</label>
<select id="addUserRole" class="input" style="width:100%">
${roleOptions}
</select>
</div>
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input id="addUserMustChange" type="checkbox" checked>
${t('members.modal.must_change_label')}
</label>
<div id="addUserError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-add-close>${t('members.modal.cancel')}</button>
<button class="btn btn-primary" type="button" id="addUserSubmit">${t('members.modal.create')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const emailInput = overlay.querySelector('#addUserEmail');
const nameInput = overlay.querySelector('#addUserName');
const pwInput = overlay.querySelector('#addUserPassword');
const genBtn = overlay.querySelector('#addUserGenerate');
const roleSelect = overlay.querySelector('#addUserRole');
const mustChange = overlay.querySelector('#addUserMustChange');
const errorEl = overlay.querySelector('#addUserError');
const submitBtn = overlay.querySelector('#addUserSubmit');
const wsSelect = overlay.querySelector('#addUserWs'); // null in fixed mode
const wsFilter = overlay.querySelector('#addUserWsFilter');
emailInput.focus();
// Picker mode: load the workspaces this platform_admin can assign into from
// /me's accessible_workspaces (already org+name shaped, all workspaces for a
// platform_admin). Filter input rebuilds the option list live.
let allWs = [];
function renderWsOptions(filter) {
const f = (filter || '').trim().toLowerCase();
const matches = f ? allWs.filter(w => wsLabel(w).toLowerCase().includes(f)) : allWs;
wsSelect.innerHTML = `<option value="">${esc(t('members.modal.workspace_placeholder'))}</option>`
+ matches.map(w => `<option value="${esc(w.id)}">${esc(wsLabel(w))}</option>`).join('');
}
if (pickerMode) {
api.getMe()
.then(me => {
allWs = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces.slice() : [];
if (!allWs.length) { wsSelect.innerHTML = `<option value="">${esc(t('members.modal.workspace_none'))}</option>`; return; }
renderWsOptions('');
})
.catch(() => { wsSelect.innerHTML = `<option value="">${esc(t('members.modal.workspace_load_error'))}</option>`; });
wsFilter.addEventListener('input', () => renderWsOptions(wsFilter.value));
}
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
function onKey(e) { if (e.key === 'Escape') close(); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelectorAll('[data-add-close]').forEach(b => b.addEventListener('click', close));
genBtn.addEventListener('click', () => { pwInput.value = generatePassword(); pwInput.type = 'text'; });
async function submit() {
errorEl.style.display = 'none';
const email = emailInput.value.trim().toLowerCase();
const name = nameInput.value.trim();
const password = pwInput.value;
const role = roleSelect.value;
const workspaceId = pickerMode ? (wsSelect.value || '') : workspace.id;
if (!email || !EMAIL_RE.test(email)) { showError(t('members.error.invalid_email')); emailInput.focus(); return; }
if (!password || password.length < 8) { showError(t('members.error.password_min_8')); pwInput.focus(); return; }
if (pickerMode && !workspaceId) { showError(t('members.modal.workspace_required')); (wsFilter || wsSelect).focus(); return; }
submitBtn.disabled = true;
submitBtn.textContent = t('members.modal.creating');
try {
const result = await api.adminCreateUser({
email, name, password, role,
workspaceId,
mustChangePassword: mustChange.checked,
});
close();
if (typeof onSuccess === 'function') {
try { onSuccess(result); }
catch (e) { console.error('add-user modal onSuccess threw:', e); }
}
} catch (err) {
submitBtn.disabled = false;
submitBtn.textContent = t('members.modal.create');
const msg = (typeof mapError === 'function')
? mapError(err)
: (err?.message || t('members.error.mutation_generic', { error: '' }));
showError(msg);
}
}
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
submitBtn.addEventListener('click', submit);
}
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

View file

@ -0,0 +1,124 @@
// Invite-member modal. Mirrors workspace-rename-modal.js's structure
// (overlay + listeners + close + esc/click-outside/enter) with two key
// differences:
//
// 1. On success calls an onSuccess(result) callback instead of
// window.location.reload(). The parent view (workspace-members.js)
// re-fetches and re-renders just the pending-invites section - no
// full-page flash for a single row addition.
//
// 2. Server errors map to translated strings via a mapError callback
// passed by the parent (mapMutationError lives in workspace-members.js).
// That keeps a single error mapper for ALL slice 2B mutations rather
// than scattering modal-specific copies. Inline display below the form
// (not toast) so user can correct + resubmit without closing.
import { api } from '../api.js';
import { t } from '../i18n.js';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// open the modal.
// workspace: { id, name } - id used for the API call, name shown in title
// opts.onSuccess: (result) => void - fires on 200; result is the server
// response body { id, email, role, expires_at }
// opts.mapError: (err) => string - translates server error to display text
export function openInviteMemberModal(workspace, opts = {}) {
const { onSuccess, mapError } = opts;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<h3>${t('members.modal.invite_title', { workspace: esc(workspace.name) })}</h3>
<button class="btn-icon" type="button" data-invite-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="inviteEmail">${t('members.modal.email_label')}</label>
<input id="inviteEmail" type="email" class="input" placeholder="${t('members.modal.email_placeholder')}" style="width:100%" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-group">
<label for="inviteRole">${t('members.modal.role_label')}</label>
<select id="inviteRole" class="input" style="width:100%">
<option value="workspace_viewer">${t('members.role.workspace_viewer')}</option>
<option value="workspace_editor">${t('members.role.workspace_editor')}</option>
<option value="workspace_admin">${t('members.role.workspace_admin')}</option>
</select>
</div>
<div id="inviteModalError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-invite-close>${t('members.modal.cancel')}</button>
<button class="btn btn-primary" type="button" id="inviteSendBtn">${t('members.modal.send')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const emailInput = overlay.querySelector('#inviteEmail');
const roleSelect = overlay.querySelector('#inviteRole');
const errorEl = overlay.querySelector('#inviteModalError');
const sendBtn = overlay.querySelector('#inviteSendBtn');
emailInput.focus();
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
function onKey(e) {
if (e.key === 'Escape') close();
else if (e.key === 'Enter' && (e.target === emailInput || e.target === roleSelect)) send();
}
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelectorAll('[data-invite-close]').forEach(b => b.addEventListener('click', close));
async function send() {
errorEl.style.display = 'none';
const email = emailInput.value.trim().toLowerCase();
const role = roleSelect.value;
// Client-side email validation - server validates too, but this avoids a
// round-trip and gives immediate feedback on obvious typos.
if (!email || !EMAIL_RE.test(email)) {
showError(t('members.error.invalid_email'));
emailInput.focus();
return;
}
sendBtn.disabled = true;
sendBtn.textContent = t('members.modal.sending');
try {
const result = await api.inviteWorkspaceMember(workspace.id, { email, role });
close();
// Defensive: undefined onSuccess is a no-op; a thrown onSuccess (parent
// bug) is logged but not propagated so the modal-close still succeeded
// from the user's perspective.
if (typeof onSuccess === 'function') {
try { onSuccess(result); }
catch (e) { console.error('invite modal onSuccess threw:', e); }
}
} catch (err) {
sendBtn.disabled = false;
sendBtn.textContent = t('members.modal.send');
// Map via parent-supplied helper. Fallback to raw message if no mapper
// was provided (shouldn't happen in normal use, defensive only).
const msg = (typeof mapError === 'function')
? mapError(err)
: (err?.message || t('members.error.mutation_generic', { error: '' }));
showError(msg);
}
}
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
sendBtn.addEventListener('click', send);
}
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

View file

@ -0,0 +1,82 @@
import { api } from '../api.js';
// Open a rename modal for the given workspace. Uses the existing .modal-overlay
// / .modal / .modal-header / .modal-body / .modal-footer CSS classes. On
// successful save, reloads the page (matches the workspace-switch flow).
export function openWorkspaceRenameModal(workspace) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal">
<div class="modal-header">
<h3>Rename workspace</h3>
<button class="btn-icon" type="button" data-rename-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="renameWsName">Name</label>
<input id="renameWsName" type="text" class="input" maxlength="80" value="${esc(workspace.name || '')}" style="width:100%">
</div>
<div class="form-group">
<label for="renameWsSlug">Slug <span style="color:var(--text-muted);font-weight:400">(optional, URL-safe)</span></label>
<input id="renameWsSlug" type="text" class="input" maxlength="60" value="${esc(workspace.slug || '')}" placeholder="e.g. studio-a" style="width:100%">
<div style="color:var(--text-muted);font-size:11px;margin-top:4px">Lowercase letters, digits, hyphens. Must be unique within the organization.</div>
</div>
<div id="renameWsError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-rename-close>Cancel</button>
<button class="btn btn-primary" type="button" id="renameWsSave">Save</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const nameInput = overlay.querySelector('#renameWsName');
const slugInput = overlay.querySelector('#renameWsSlug');
const errorEl = overlay.querySelector('#renameWsError');
const saveBtn = overlay.querySelector('#renameWsSave');
nameInput.focus();
nameInput.select();
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
function onKey(e) {
if (e.key === 'Escape') close();
else if (e.key === 'Enter' && (e.target === nameInput || e.target === slugInput)) save();
}
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelectorAll('[data-rename-close]').forEach(b => b.addEventListener('click', close));
async function save() {
errorEl.style.display = 'none';
const name = nameInput.value.trim();
const slug = slugInput.value.trim();
if (!name) { showError('Name cannot be empty'); return; }
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
await api.renameWorkspace(workspace.id, { name, slug });
window.location.reload();
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
showError(err.message || 'Rename failed');
}
}
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
saveBtn.addEventListener('click', save);
}
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

View file

@ -0,0 +1,244 @@
import { api } from '../api.js';
import { showToast } from './toast.js';
import { t, tn } from '../i18n.js';
// Reusable resource-count formatter. Returns localized "1 device" / "N devices"
// / "No devices" based on n. Generic so the same shape can wire users /
// playlists / schedules counts later without refactor - caller supplies the
// i18n key bases.
// keyBase: e.g. 'switcher.devices_count' (looks up _one / _other variants via tn)
// zeroKey: e.g. 'switcher.no_devices' (direct lookup for n === 0)
function formatResourceCount(n, keyBase, zeroKey) {
if (n === undefined || n === null) return '';
if (n === 0) return t(zeroKey);
return tn(keyBase, n);
}
// Admin affordances shown beside a workspace: manage members + rename. Returns
// '' for non-admins. Shared by the single-workspace view and the multi-workspace
// dropdown items so the two never drift - #19: the single view was missing these,
// locking single-workspace users out of org settings (invite users, perms, slug).
function adminIconsHtml(w) {
if (!w.can_admin) return '';
return `
<button class="workspace-switcher-members" type="button" data-members-id="${esc(w.id)}" aria-label="Manage members" title="Manage members">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</button>
<button class="workspace-switcher-pencil" type="button" data-rename-id="${esc(w.id)}" aria-label="Rename workspace" title="Rename">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
</svg>
</button>`;
}
// Wire the manage-members + rename buttons within `scope`. `list` resolves a
// workspace id to its object (for the rename modal). stopPropagation so a click
// on an icon never triggers the row's switch handler.
function wireAdminIcons(scope, list) {
scope.querySelectorAll('.workspace-switcher-pencil').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const ws = list.find(w => w.id === btn.dataset.renameId);
if (!ws) return;
scope.classList.remove('open');
const { openWorkspaceRenameModal } = await import('./workspace-rename-modal.js');
openWorkspaceRenameModal(ws);
});
});
scope.querySelectorAll('.workspace-switcher-members').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
scope.classList.remove('open');
window.location.hash = `#/workspace/${btn.dataset.membersId}/members`;
});
});
}
// Render the workspace switcher inside #workspaceSwitcher based on the
// /api/auth/me response. Three modes:
// - 0 accessible workspaces: muted "No workspace" placeholder
// - 1 accessible workspace: workspace name as static text
// - >1 accessible workspaces: dropdown button + menu with click-to-switch
export function renderWorkspaceSwitcher(me) {
const container = document.getElementById('workspaceSwitcher');
if (!container) return;
const list = Array.isArray(me?.accessible_workspaces) ? me.accessible_workspaces : [];
const currentId = me?.current_workspace_id || null;
if (list.length === 0) {
container.classList.remove('open');
container.innerHTML = `<span class="workspace-switcher-empty">No workspace</span>`;
return;
}
if (list.length === 1) {
// #19: a single workspace still needs its admin affordances (manage members /
// rename + slug). Render the name as before, plus the inline manage icons
// when the user can administer it - no dropdown for one item.
container.classList.remove('open');
const only = list[0];
container.innerHTML = `
<div class="workspace-switcher-single">
<span class="workspace-switcher-static">${esc(only.name)}</span>
${adminIconsHtml(only)}
</div>`;
wireAdminIcons(container, [only]);
return;
}
// >1: dropdown. Alpha sort by workspace name for MVP (no recently-used yet).
const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name));
const current = sorted.find(w => w.id === currentId) || sorted[0];
// Issue #16: show a type-to-filter search box once the list is big enough to
// be painful to scroll (MSPs run 100+ orgs). Below the threshold a plain list
// is fine. The full list is already loaded from /me, so filtering is client-side.
const SHOW_SEARCH_THRESHOLD = 8;
const showSearch = sorted.length >= SHOW_SEARCH_THRESHOLD;
container.innerHTML = `
<button class="workspace-switcher-button" type="button" aria-haspopup="listbox" aria-expanded="false">
<span class="ws-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(current.name)}</span>
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<div class="workspace-switcher-menu" role="listbox">
${showSearch ? `
<div class="workspace-switcher-search">
<input type="text" class="ws-search-input" placeholder="${t('switcher.search_placeholder')}"
autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="${t('switcher.search_placeholder')}">
</div>` : ''}
${sorted.map(w => {
const countStr = formatResourceCount(w.device_count, 'switcher.devices_count', 'switcher.no_devices');
const orgName = w.organization_name || '';
const subtitle = orgName && countStr ? esc(orgName) + ' · ' + esc(countStr)
: orgName ? esc(orgName)
: countStr ? esc(countStr)
: '';
// Searchable haystack: org name + workspace name, lowercased.
const haystack = `${orgName} ${w.name}`.toLowerCase();
return `
<div class="workspace-switcher-item ${w.id === currentId ? 'current' : ''}" data-workspace-id="${esc(w.id)}" data-search="${esc(haystack)}" role="option">
<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" style="${w.id === currentId ? '' : 'visibility:hidden'}">
<polyline points="20 6 9 17 4 12"/>
</svg>
<div class="ws-meta">
<div class="ws-name">${esc(w.name)}</div>
<div class="ws-org">${subtitle}</div>
</div>
${adminIconsHtml(w)}
</div>
`;
}).join('')}
<div class="workspace-switcher-noresults" style="display:none">${t('switcher.no_matches')}</div>
</div>
`;
const button = container.querySelector('.workspace-switcher-button');
const searchInput = container.querySelector('.ws-search-input'); // null below threshold
// Shared switch action (used by click and keyboard Enter).
async function switchTo(wsId) {
if (wsId === currentId) { container.classList.remove('open'); return; }
try {
const resp = await api.switchWorkspace(wsId);
if (resp?.token) {
localStorage.setItem('token', resp.token);
window.location.reload();
} else {
showToast('Switch returned no token', 'error');
}
} catch (err) {
showToast(err.message || 'Failed to switch workspace', 'error');
}
}
// ---- type-to-filter + keyboard navigation (only when the search box renders) ----
const allItems = Array.from(container.querySelectorAll('.workspace-switcher-item'));
const noResults = container.querySelector('.workspace-switcher-noresults');
let highlightIdx = -1;
const visibleItems = () => allItems.filter(it => it.style.display !== 'none');
function setHighlight(idx) {
const vis = visibleItems();
allItems.forEach(it => it.classList.remove('highlighted'));
if (!vis.length) { highlightIdx = -1; return; }
highlightIdx = Math.max(0, Math.min(idx, vis.length - 1));
const el = vis[highlightIdx];
el.classList.add('highlighted');
el.scrollIntoView({ block: 'nearest' });
}
function applyFilter(q) {
const query = (q || '').trim().toLowerCase();
let anyVisible = false;
for (const it of allItems) {
const match = !query || it.dataset.search.includes(query);
it.style.display = match ? '' : 'none';
if (match) anyVisible = true;
}
if (noResults) noResults.style.display = anyVisible ? 'none' : '';
setHighlight(0);
}
if (searchInput) {
searchInput.addEventListener('input', () => applyFilter(searchInput.value));
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(highlightIdx + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(highlightIdx - 1); }
else if (e.key === 'Enter') {
e.preventDefault();
const el = visibleItems()[highlightIdx];
if (el) switchTo(el.dataset.workspaceId);
} else if (e.key === 'Escape') {
e.preventDefault();
container.classList.remove('open');
button.setAttribute('aria-expanded', 'false');
button.focus();
}
});
}
button.addEventListener('click', (e) => {
e.stopPropagation();
const opening = !container.classList.contains('open');
container.classList.toggle('open');
button.setAttribute('aria-expanded', String(opening));
// On open, reset the filter and focus the search box for immediate typing.
if (opening && searchInput) {
searchInput.value = '';
applyFilter('');
setTimeout(() => searchInput.focus(), 0);
}
});
// Manage-members + rename icons (shared with the single-workspace view).
wireAdminIcons(container, sorted);
container.querySelectorAll('.workspace-switcher-item').forEach(item => {
item.addEventListener('click', (e) => {
// Ignore clicks that originated on an icon button (each has its own handler).
if (e.target.closest('.workspace-switcher-pencil, .workspace-switcher-members')) return;
switchTo(item.dataset.workspaceId);
});
});
// Click-outside closes the menu.
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) {
container.classList.remove('open');
button.setAttribute('aria-expanded', 'false');
}
});
}
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}

View file

@ -1,147 +1,61 @@
const translations = { // Lightweight i18n loader. Each language is its own file under ./i18n/ so a
en: { // translator can edit one file without touching the others. English is the
// Nav // canonical source — every other locale falls back to en for any missing key.
'nav.displays': 'Displays', import en from './i18n/en.js';
'nav.content': 'Content', import es from './i18n/es.js';
'nav.layouts': 'Layouts', import fr from './i18n/fr.js';
'nav.widgets': 'Widgets', import de from './i18n/de.js';
'nav.schedule': 'Schedule', import pt from './i18n/pt.js';
'nav.walls': 'Video Walls', import hi from './i18n/hi.js';
'nav.reports': 'Reports', import it from './i18n/it.js';
'nav.designer': 'Designer',
'nav.activity': 'Activity', const fallback = en;
'nav.settings': 'Settings', const registry = { en, es, fr, de, pt, hi, it };
'nav.subscription': 'Subscription',
// Dashboard
'dashboard.title': 'Displays',
'dashboard.subtitle': 'Manage your remote displays',
'dashboard.add': 'Add Display',
'dashboard.search': 'Search displays...',
'dashboard.all_status': 'All Status',
'dashboard.online': 'Online',
'dashboard.offline': 'Offline',
'dashboard.no_displays': 'No displays yet',
'dashboard.no_displays_desc': 'Install the ScreenTinker app on your TV and pair it using the button above.',
// Content
'content.title': 'Content Library',
'content.subtitle': 'Upload and manage your media files',
'content.drop': 'Drop files here or click to upload',
'content.remote_url': 'Remote URL',
'content.no_content': 'No content yet',
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.loading': 'Loading...',
'common.connected': 'Connected',
'common.disconnected': 'Disconnected',
// Auth
'auth.sign_in': 'Sign In',
'auth.create_account': 'Create Account',
'auth.email': 'Email',
'auth.password': 'Password',
'auth.name': 'Name',
'auth.sign_out': 'Sign out',
},
es: {
'nav.displays': 'Pantallas',
'nav.content': 'Contenido',
'nav.layouts': 'Diseños',
'nav.widgets': 'Widgets',
'nav.schedule': 'Horario',
'nav.walls': 'Video Walls',
'nav.reports': 'Informes',
'nav.designer': 'Diseñador',
'nav.activity': 'Actividad',
'nav.settings': 'Configuración',
'nav.subscription': 'Suscripción',
'dashboard.title': 'Pantallas',
'dashboard.subtitle': 'Administra tus pantallas remotas',
'dashboard.add': 'Agregar Pantalla',
'dashboard.search': 'Buscar pantallas...',
'dashboard.all_status': 'Todos los estados',
'dashboard.online': 'En línea',
'dashboard.offline': 'Desconectado',
'dashboard.no_displays': 'Aún no hay pantallas',
'content.title': 'Biblioteca de Contenido',
'content.subtitle': 'Sube y administra tus archivos multimedia',
'content.drop': 'Arrastra archivos aquí o haz clic para subir',
'content.remote_url': 'URL Remota',
'common.save': 'Guardar',
'common.cancel': 'Cancelar',
'common.delete': 'Eliminar',
'common.edit': 'Editar',
'common.loading': 'Cargando...',
'common.connected': 'Conectado',
'common.disconnected': 'Desconectado',
'auth.sign_in': 'Iniciar Sesión',
'auth.create_account': 'Crear Cuenta',
'auth.email': 'Correo electrónico',
'auth.password': 'Contraseña',
'auth.name': 'Nombre',
'auth.sign_out': 'Cerrar sesión',
},
fr: {
'nav.displays': 'Écrans',
'nav.content': 'Contenu',
'nav.layouts': 'Mises en page',
'nav.widgets': 'Widgets',
'nav.schedule': 'Calendrier',
'nav.walls': 'Murs vidéo',
'nav.reports': 'Rapports',
'nav.designer': 'Concepteur',
'nav.activity': 'Activité',
'nav.settings': 'Paramètres',
'nav.subscription': 'Abonnement',
'dashboard.title': 'Écrans',
'dashboard.subtitle': 'Gérez vos écrans distants',
'dashboard.add': 'Ajouter un écran',
'dashboard.search': 'Rechercher des écrans...',
'common.save': 'Enregistrer',
'common.cancel': 'Annuler',
'common.delete': 'Supprimer',
'common.loading': 'Chargement...',
'auth.sign_in': 'Se connecter',
'auth.create_account': 'Créer un compte',
'auth.sign_out': 'Se déconnecter',
},
de: {
'nav.displays': 'Bildschirme',
'nav.content': 'Inhalt',
'nav.layouts': 'Layouts',
'nav.widgets': 'Widgets',
'nav.schedule': 'Zeitplan',
'nav.walls': 'Videowände',
'nav.reports': 'Berichte',
'nav.designer': 'Designer',
'nav.activity': 'Aktivität',
'nav.settings': 'Einstellungen',
'nav.subscription': 'Abonnement',
'dashboard.title': 'Bildschirme',
'dashboard.subtitle': 'Verwalten Sie Ihre Remote-Displays',
'dashboard.add': 'Bildschirm hinzufügen',
'dashboard.search': 'Bildschirme suchen...',
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
'common.delete': 'Löschen',
'common.loading': 'Laden...',
'auth.sign_in': 'Anmelden',
'auth.create_account': 'Konto erstellen',
'auth.sign_out': 'Abmelden',
},
};
let currentLang = localStorage.getItem('rd_lang') || navigator.language?.split('-')[0] || 'en'; let currentLang = localStorage.getItem('rd_lang') || navigator.language?.split('-')[0] || 'en';
if (!translations[currentLang]) currentLang = 'en'; if (!registry[currentLang]) currentLang = 'en';
export function t(key) { function lookup(key) {
return translations[currentLang]?.[key] || translations.en[key] || key; return registry[currentLang]?.[key] ?? fallback[key] ?? key;
}
// Replace {name} placeholders in a string with the matching property of vars.
// Unknown placeholders pass through unchanged so a missing var is visible
// during development rather than silently dropped.
function format(s, vars) {
if (!vars) return s;
return s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
}
export function t(key, vars) {
return format(lookup(key), vars);
}
// Plural helper: looks up `${keyBase}_one` for n===1 else `${keyBase}_other`,
// auto-injects `{n}` into vars. Use for any string that varies on a count.
export function tn(keyBase, n, vars = {}) {
const key = keyBase + (n === 1 ? '_one' : '_other');
return format(lookup(key), { n, ...vars });
}
const subscribers = new Set();
// Views and the navbar subscribe so they can rebuild themselves on language
// change. Also fires a `language-changed` CustomEvent and a hashchange so the
// existing hash router naturally re-renders the current view.
export function subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
} }
export function setLanguage(lang) { export function setLanguage(lang) {
if (!registry[lang] || lang === currentLang) return;
currentLang = lang; currentLang = lang;
localStorage.setItem('rd_lang', lang); localStorage.setItem('rd_lang', lang);
document.documentElement.setAttribute('lang', lang);
subscribers.forEach((fn) => { try { fn(lang); } catch {} });
window.dispatchEvent(new CustomEvent('language-changed', { detail: { lang } }));
window.dispatchEvent(new HashChangeEvent('hashchange'));
} }
export function getLanguage() { export function getLanguage() {
@ -153,6 +67,15 @@ export function getAvailableLanguages() {
{ code: 'en', name: 'English' }, { code: 'en', name: 'English' },
{ code: 'es', name: 'Español' }, { code: 'es', name: 'Español' },
{ code: 'fr', name: 'Français' }, { code: 'fr', name: 'Français' },
{ code: 'it', name: 'Italiano' },
{ code: 'de', name: 'Deutsch' }, { code: 'de', name: 'Deutsch' },
{ code: 'pt', name: 'Português' },
{ code: 'hi', name: 'हिन्दी' },
]; ];
} }
// Apply the persisted language to <html lang=...> on first load so screen
// readers and CSS :lang() selectors are accurate before any user interaction.
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('lang', currentLang);
}

1126
frontend/js/i18n/de.js Normal file

File diff suppressed because it is too large Load diff

1403
frontend/js/i18n/en.js Normal file

File diff suppressed because it is too large Load diff

1125
frontend/js/i18n/es.js Normal file

File diff suppressed because it is too large Load diff

1126
frontend/js/i18n/fr.js Normal file

File diff suppressed because it is too large Load diff

18
frontend/js/i18n/hi.js Normal file
View file

@ -0,0 +1,18 @@
// Hindi translations — INTENTIONALLY SKELETON.
//
// We have an active user in India. Rather than ship machine-quality Hindi that
// could read as unprofessional or get formality register / gendered verbs
// wrong, this file is empty: every key falls back to English via the t()
// loader. When a native speaker reviews and fills in keys here, those keys
// take effect immediately without any code change in views.
//
// Translation guidelines for whoever fills this in:
// - Use formal आप register (this is B2B software, not consumer chat).
// - Keep technical terms in English when borrowed (Playlist, YouTube, MIME)
// — these are familiar to Indian users in their English form.
// - Translate UI verbs (Save, Cancel, etc.) into proper Hindi.
// - Test on the dashboard and content views first; those are wired to t().
//
// To add a key: copy from en.js and translate the value. Order doesn't matter;
// the loader merges over English fallback.
export default {};

1108
frontend/js/i18n/it.js Normal file

File diff suppressed because it is too large Load diff

1126
frontend/js/i18n/pt.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,11 @@ const listeners = new Map();
export function connectSocket() { export function connectSocket() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
dashboardSocket = io('/dashboard', { dashboardSocket = io('/dashboard', {
auth: { token } auth: { token },
// Prefer WebSocket; fall back to polling on the same connect attempt.
// Mirrors the player-side fix in 1aee4f2 - skips the polling->WS upgrade
// dance that was causing the dashboard socket to flicker on Apply.
transports: ['websocket', 'polling']
}); });
dashboardSocket.on('connect', () => { dashboardSocket.on('connect', () => {
@ -48,6 +52,22 @@ export function connectSocket() {
emit('playback-state', data); emit('playback-state', data);
}); });
// Live device debug log line (device-detail screen streams these when the
// per-device "Debug logging" checkbox is on).
dashboardSocket.on('dashboard:device-log', (data) => {
emit('device-log', data);
});
// Playback progress (play_start with duration — drives device-card progress bars)
dashboardSocket.on('dashboard:playback-progress', (data) => {
emit('playback-progress', data);
});
// Wall changed — dashboard refreshes wall cards + device-grouping layout
dashboardSocket.on('dashboard:wall-changed', () => {
emit('wall-changed');
});
// Content ack // Content ack
dashboardSocket.on('dashboard:content-ack', (data) => { dashboardSocket.on('dashboard:content-ack', (data) => {
emit('content-ack', data); emit('content-ack', data);
@ -109,8 +129,20 @@ export function sendKey(deviceId, keycode) {
if (dashboardSocket) dashboardSocket.emit('dashboard:remote-key', { device_id: deviceId, keycode }); if (dashboardSocket) dashboardSocket.emit('dashboard:remote-key', { device_id: deviceId, keycode });
} }
export function sendCommand(deviceId, type, payload) { // Optional callback receives the server-side ack: { delivered, queued, reason }.
if (dashboardSocket) dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload }); // Callers without a callback keep firing-and-forgetting (no behavior change).
// With a callback, we use Socket.IO's .timeout() so the callback always fires -
// either with the ack or with an Error if the server doesn't respond in 5s.
export function sendCommand(deviceId, type, payload, callback) {
if (!dashboardSocket) return;
if (typeof callback === 'function') {
dashboardSocket.timeout(5000).emit('dashboard:device-command', { device_id: deviceId, type, payload }, (err, ack) => {
if (err) callback({ delivered: false, reason: 'no_ack' });
else callback(ack || { delivered: false, reason: 'no_ack' });
});
} else {
dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload });
}
} }
export function getSocket() { return dashboardSocket; } export function getSocket() { return dashboardSocket; }

View file

@ -3,3 +3,11 @@ export function esc(str) {
if (str == null) return ''; if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
} }
// Phase 2.1: the Phase 1 schema migration renamed the legacy 'superadmin'
// role to 'platform_admin'. Existing frontend checks still match the old
// string; this helper accepts both so we don't have to splatter the array
// at every call site. Use everywhere the UI gates on platform-level access.
export function isPlatformAdmin(user) {
return !!(user && (user.role === 'superadmin' || user.role === 'platform_admin'));
}

View file

@ -1,16 +1,17 @@
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
import { t } from '../i18n.js';
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json()); const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
export async function render(container) { export async function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div><h1>Activity Log</h1><div class="subtitle">Audit trail of all actions</div></div> <div><h1>${t('activity.title')}</h1><div class="subtitle">${t('activity.subtitle')}</div></div>
</div> </div>
<div id="activityList"><div class="empty-state"><h3>Loading...</h3></div></div> <div id="activityList"><div class="empty-state"><h3>${t('common.loading')}</h3></div></div>
<div style="text-align:center;margin-top:16px"> <div style="text-align:center;margin-top:16px">
<button class="btn btn-secondary btn-sm" id="loadMoreBtn" style="display:none">Load More</button> <button class="btn btn-secondary btn-sm" id="loadMoreBtn" style="display:none">${t('activity.load_more')}</button>
</div> </div>
`; `;
@ -25,14 +26,14 @@ export async function render(container) {
if (!append) list.innerHTML = ''; if (!append) list.innerHTML = '';
if (items.length === 0 && offset === 0) { if (items.length === 0 && offset === 0) {
list.innerHTML = '<div class="empty-state"><h3>No activity yet</h3><p>Actions will appear here as you use the system.</p></div>'; list.innerHTML = `<div class="empty-state"><h3>${t('activity.empty_title')}</h3><p>${t('activity.empty_desc')}</p></div>`;
return; return;
} }
const html = items.map(item => { const html = items.map(item => {
const time = new Date(item.created_at * 1000); const time = new Date(item.created_at * 1000);
const timeStr = time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' + const timeStr = time.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const icon = getActionIcon(item.action); const icon = getActionIcon(item.action);
return ` return `
@ -40,7 +41,7 @@ export async function render(container) {
<div style="width:32px;height:32px;border-radius:50%;background:var(--bg-card);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:14px">${icon}</div> <div style="width:32px;height:32px;border-radius:50%;background:var(--bg-card);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:14px">${icon}</div>
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<div style="font-size:13px"> <div style="font-size:13px">
<strong>${esc(item.user_name || item.user_email || 'System')}</strong> <strong>${esc(item.user_name || item.user_email || t('activity.system'))}</strong>
<span style="color:var(--text-secondary)"> ${esc(formatAction(item.action))}</span> <span style="color:var(--text-secondary)"> ${esc(formatAction(item.action))}</span>
</div> </div>
${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(item.details)}</div>` : ''} ${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(item.details)}</div>` : ''}
@ -81,22 +82,29 @@ function getActionIcon(action) {
return '&#128196;'; return '&#128196;';
} }
// Action verbs are user-visible; translate them through t() so they switch
// languages with the rest of the UI. The mapping below preserves the original
// verb-then-noun structure of the English version.
function formatAction(action) { function formatAction(action) {
return action // Verbs
.replace('POST /api/', 'created ') let s = action
.replace('PUT /api/', 'updated ') .replace('POST /api/', t('activity.verb_created') + ' ')
.replace('DELETE /api/', 'deleted ') .replace('PUT /api/', t('activity.verb_updated') + ' ')
.replace('/provision/pair', 'paired a device') .replace('DELETE /api/', t('activity.verb_deleted') + ' ');
.replace('/content/remote', 'added remote content') // Specific endpoints
.replace('/content', 'content') s = s
.replace('/devices/:id', 'device') .replace('/provision/pair', t('activity.action_paired_device'))
.replace('/assignments/device/:deviceId', 'playlist assignment') .replace('/content/remote', t('activity.action_added_remote_content'))
.replace('/assignments/:id', 'assignment') .replace('/content', t('activity.noun_content'))
.replace('/layouts', 'layout') .replace('/devices/:id', t('activity.noun_device'))
.replace('/widgets', 'widget') .replace('/assignments/device/:deviceId', t('activity.noun_playlist_assignment'))
.replace('/schedules', 'schedule') .replace('/assignments/:id', t('activity.noun_assignment'))
.replace('/walls', 'video wall') .replace('/layouts', t('activity.noun_layout'))
.replace('alert:device_offline', 'alert: device went offline'); .replace('/widgets', t('activity.noun_widget'))
.replace('/schedules', t('activity.noun_schedule'))
.replace('/walls', t('activity.noun_video_wall'))
.replace('alert:device_offline', t('activity.alert_device_offline'));
return s;
} }
export function cleanup() {} export function cleanup() {}

View file

@ -0,0 +1,336 @@
// Admin view for the player_debug_logs telemetry sink. Platform-admin only.
// Mounted at #/admin/player-debug. Reads from /api/player-debug/list,
// /api/player-debug/summary, /api/player-debug/older-than (DELETE).
//
// Server-side pagination - we never render all 10k rows at once. Page param
// in the URL hash so refresh preserves position.
//
// IMPORTANT: device_id is whatever the player POSTed. The submitter is
// unauthenticated by design (so unpaired players can also send), which means
// device_id is self-reported, NOT server-verified. Surfaced via column label
// "device_id (self-reported)" and the help-text caption below the filters.
import { isPlatformAdmin } from '../utils.js';
import { showToast } from '../components/toast.js';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts });
// Parse a query string from a hash like '#/admin/player-debug?page=2&ua=Tizen'.
// Returns a plain object - no URLSearchParams since the hash format isn't
// a standard URL.
function parseHashParams() {
const h = window.location.hash || '';
const qi = h.indexOf('?');
if (qi < 0) return {};
const out = {};
const qs = h.substring(qi + 1);
for (const part of qs.split('&')) {
if (!part) continue;
const eq = part.indexOf('=');
const k = eq >= 0 ? part.substring(0, eq) : part;
const v = eq >= 0 ? part.substring(eq + 1) : '';
try { out[decodeURIComponent(k)] = decodeURIComponent(v); } catch { out[k] = v; }
}
return out;
}
function setHashParams(updates) {
const base = '#/admin/player-debug';
const merged = { ...parseHashParams(), ...updates };
// Strip empty values so the URL stays tidy
const pairs = [];
for (const [k, v] of Object.entries(merged)) {
if (v == null || v === '') continue;
pairs.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
}
// Replace, don't push - we don't want every filter keystroke in browser history
history.replaceState(null, '', pairs.length ? base + '?' + pairs.join('&') : base);
}
// Pretty-print JSON for the expanded-row display. Returns the original string
// if parsing fails so we don't lose data when the field isn't JSON-shaped.
function prettyJson(s) {
if (s == null || s === '') return '(empty)';
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return String(s);
}
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function fmtTime(unixSec) {
if (!unixSec) return '';
try { return new Date(unixSec * 1000).toLocaleString(); } catch { return String(unixSec); }
}
function uaShort(ua) {
if (!ua) return '';
// Keep just the part most useful for at-a-glance scanning. Full UA in the
// expanded row.
return ua.length > 60 ? ua.substring(0, 60) + '...' : ua;
}
export async function render(container) {
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (!isPlatformAdmin(user)) {
container.innerHTML = '<div class="empty-state"><h3>Access denied</h3><p>Platform-admin role required.</p></div>';
return;
}
const params = parseHashParams();
const currentPage = parseInt(params.page) || 1;
const currentUa = params.ua || '';
const currentSince = params.since || '';
const currentUntil = params.until || '';
const currentHasError = params.has_error === '1';
container.innerHTML = `
<div class="page-header">
<div>
<h1>Player Debug Logs</h1>
<div class="subtitle">Captured errors and state from player clients. Mostly smart TVs we can't reach with devtools.</div>
</div>
</div>
<div class="settings-section">
<h3>Summary</h3>
<div id="pdSummary" style="display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--text-secondary)">Loading...</div>
</div>
<div class="settings-section">
<h3>Filters</h3>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
<div>
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">User agent contains</label>
<input class="input" id="pdFilterUa" value="${esc(currentUa)}" placeholder="Tizen, WebOS, AFTS..." style="width:220px">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">Since (YYYY-MM-DD)</label>
<input class="input" id="pdFilterSince" value="${esc(currentSince)}" placeholder="2026-05-01" style="width:140px">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">Until (YYYY-MM-DD)</label>
<input class="input" id="pdFilterUntil" value="${esc(currentUntil)}" placeholder="2026-05-31" style="width:140px">
</div>
<div>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer">
<input type="checkbox" id="pdFilterHasError" ${currentHasError ? 'checked' : ''}> Has error data
</label>
</div>
<button class="btn btn-primary btn-sm" id="pdApplyFilters">Apply</button>
<button class="btn btn-secondary btn-sm" id="pdClearFilters">Clear</button>
<div style="flex:1"></div>
<button class="btn btn-danger btn-sm" id="pdDeleteOld">Delete older than 30 days</button>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:10px">
Note: <code>device_id</code> is self-reported by the player and is not server-verified. The submission endpoint is unauthenticated by design so unpaired players can also report errors.
</div>
</div>
<div class="settings-section">
<h3>Logs <span id="pdRowMeta" style="font-size:13px;color:var(--text-muted);font-weight:400"></span></h3>
<div id="pdList"><p style="color:var(--text-muted)">Loading...</p></div>
<div id="pdPagination" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:14px"></div>
</div>
`;
// ---- handlers ----
document.getElementById('pdApplyFilters').onclick = () => {
const ua = document.getElementById('pdFilterUa').value.trim();
const since = document.getElementById('pdFilterSince').value.trim();
const until = document.getElementById('pdFilterUntil').value.trim();
const hasError = document.getElementById('pdFilterHasError').checked ? '1' : '';
setHashParams({ page: 1, ua, since, until, has_error: hasError });
loadList();
};
document.getElementById('pdClearFilters').onclick = () => {
document.getElementById('pdFilterUa').value = '';
document.getElementById('pdFilterSince').value = '';
document.getElementById('pdFilterUntil').value = '';
document.getElementById('pdFilterHasError').checked = false;
setHashParams({ page: 1, ua: '', since: '', until: '', has_error: '' });
loadList();
};
document.getElementById('pdDeleteOld').onclick = async () => {
if (!confirm('Delete all logs older than 30 days? This cannot be undone.')) return;
try {
const res = await API('/player-debug/older-than?days=30', { method: 'DELETE' });
const data = await res.json();
showToast(`Deleted ${data.deleted} log${data.deleted === 1 ? '' : 's'} older than 30 days`, 'success');
loadSummary();
loadList();
} catch (err) {
showToast('Delete failed: ' + (err.message || err), 'error');
}
};
loadSummary();
loadList();
}
async function loadSummary() {
const el = document.getElementById('pdSummary');
if (!el) return;
try {
const res = await API('/player-debug/summary');
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const families = [
['Tizen', data.byFamily.tizen, '#3b82f6'],
['WebOS', data.byFamily.webos, '#a3e635'],
['Fire TV', data.byFamily.fire_tv, '#f97316'],
['Bravia', data.byFamily.bravia, '#a855f7'],
['Edge', data.byFamily.edge, '#06b6d4'],
['Chrome', data.byFamily.chrome, '#fbbf24'],
['Firefox', data.byFamily.firefox, '#ef4444'],
['Safari', data.byFamily.safari, '#64748b'],
['Other', data.byFamily.other, '#94a3b8'],
];
el.innerHTML = `
<div style="font-weight:600;color:var(--text-primary)">Total: ${data.total}</div>
${families.map(([name, count, color]) => `
<div style="display:flex;align-items:center;gap:6px">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${color}"></span>
<span>${name}: <strong style="color:var(--text-primary)">${count}</strong></span>
</div>
`).join('')}
`;
} catch (err) {
el.innerHTML = '<span style="color:var(--danger)">Failed to load summary: ' + esc(err.message || err) + '</span>';
}
}
function ymdToUnix(s, endOfDay) {
if (!s) return '';
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return '';
const [, y, mo, d] = m;
const dt = new Date(Date.UTC(+y, +mo - 1, +d, endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0));
return Math.floor(dt.getTime() / 1000);
}
async function loadList() {
const el = document.getElementById('pdList');
const meta = document.getElementById('pdRowMeta');
const pag = document.getElementById('pdPagination');
if (!el) return;
el.innerHTML = '<p style="color:var(--text-muted)">Loading...</p>';
const params = parseHashParams();
const page = Math.max(1, parseInt(params.page) || 1);
const limit = 50;
const qs = new URLSearchParams();
qs.set('page', page);
qs.set('limit', limit);
if (params.ua) qs.set('ua_contains', params.ua);
const since = ymdToUnix(params.since, false);
const until = ymdToUnix(params.until, true);
if (since) qs.set('since', since);
if (until) qs.set('until', until);
if (params.has_error === '1') qs.set('has_error', '1');
try {
const res = await API('/player-debug/list?' + qs.toString());
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const totalPages = Math.max(1, Math.ceil(data.total / data.limit));
meta.textContent = `(${data.total} total, page ${data.page} of ${totalPages})`;
if (data.rows.length === 0) {
el.innerHTML = '<p style="color:var(--text-muted);padding:14px 0">No logs match the current filters.</p>';
} else {
el.innerHTML = `
<div class="table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:960px">
<thead><tr style="border-bottom:1px solid var(--border);text-align:left">
<th style="padding:8px;width:50px">ID</th>
<th style="padding:8px;width:140px">Time</th>
<th style="padding:8px;width:180px" title="Self-reported by the player; not server-verified.">device_id (self-reported)</th>
<th style="padding:8px;width:130px">IP</th>
<th style="padding:8px">User agent</th>
<th style="padding:8px;width:130px">Fingerprint</th>
<th style="padding:8px;width:80px"></th>
</tr></thead>
<tbody>
${data.rows.map(r => `
<tr style="border-bottom:1px solid var(--border-light)" data-row-id="${r.id}">
<td style="padding:8px;font-family:monospace;color:var(--text-muted)">${r.id}</td>
<td style="padding:8px;font-size:12px">${esc(fmtTime(r.created_at))}</td>
<td style="padding:8px;font-family:monospace;font-size:11px;color:var(--text-secondary)">${esc(r.device_id || '(none)')}</td>
<td style="padding:8px;font-family:monospace;font-size:12px;color:var(--text-secondary)">${esc(r.ip || '')}</td>
<td style="padding:8px;font-size:12px;color:var(--text-secondary)">${esc(uaShort(r.user_agent))}</td>
<td style="padding:8px;font-family:monospace;font-size:11px;color:var(--text-muted)">${esc(r.error_fingerprint || '')}</td>
<td style="padding:8px;text-align:right">
<button class="btn btn-secondary btn-sm" data-expand="${r.id}" style="font-size:11px;padding:2px 8px">Expand</button>
</td>
</tr>
<tr style="display:none" data-expanded-for="${r.id}">
<td colspan="7" style="padding:12px 16px;background:var(--bg-input)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">URL</div>
<div style="font-family:monospace;font-size:11px;color:var(--text-secondary);word-break:break-all;margin-bottom:10px">${esc(r.url || '(none)')}</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">Full User Agent</div>
<div style="font-family:monospace;font-size:11px;color:var(--text-secondary);word-break:break-all;margin-bottom:10px">${esc(r.user_agent || '(none)')}</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">error_data</div>
<pre style="margin:0;padding:8px;background:var(--bg-primary);border-radius:4px;font-family:monospace;font-size:11px;color:var(--text-secondary);overflow:auto;max-height:300px;white-space:pre-wrap;word-break:break-word">${esc(prettyJson(r.error_data))}</pre>
</div>
<div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">context</div>
<pre style="margin:0;padding:8px;background:var(--bg-primary);border-radius:4px;font-family:monospace;font-size:11px;color:var(--text-secondary);overflow:auto;max-height:420px;white-space:pre-wrap;word-break:break-word">${esc(prettyJson(r.context))}</pre>
</div>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
el.querySelectorAll('button[data-expand]').forEach(btn => {
btn.onclick = () => {
const id = btn.getAttribute('data-expand');
const exp = el.querySelector(`tr[data-expanded-for="${id}"]`);
if (exp) {
const visible = exp.style.display !== 'none';
exp.style.display = visible ? 'none' : '';
btn.textContent = visible ? 'Expand' : 'Collapse';
}
};
});
}
// ---- pagination ----
pag.innerHTML = '';
if (totalPages > 1) {
const prev = document.createElement('button');
prev.className = 'btn btn-secondary btn-sm';
prev.textContent = '< Prev';
prev.disabled = page <= 1;
prev.onclick = () => { setHashParams({ page: page - 1 }); loadList(); };
pag.appendChild(prev);
const indicator = document.createElement('span');
indicator.style.cssText = 'padding:0 12px;font-size:13px;color:var(--text-muted)';
indicator.textContent = `Page ${page} of ${totalPages}`;
pag.appendChild(indicator);
const next = document.createElement('button');
next.className = 'btn btn-secondary btn-sm';
next.textContent = 'Next >';
next.disabled = page >= totalPages;
next.onclick = () => { setHashParams({ page: page + 1 }); loadList(); };
pag.appendChild(next);
}
} catch (err) {
el.innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(err.message || err) + '</p>';
}
}

View file

@ -1,121 +1,341 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc, isPlatformAdmin } from '../utils.js';
import { t } from '../i18n.js';
import { openAddUserModal } from '../components/workspace-members-add-user-modal.js';
import { openManageWorkspacesModal } from '../components/admin-user-workspaces-modal.js';
import { openCreateOrgModal } from '../components/admin-create-org-modal.js';
import { openTypeToConfirmModal } from '../components/type-to-confirm-modal.js';
// Reuse the members view's server-error -> friendly-string mapper (handles the
// 409 duplicate-email / weak-password / invalid-email cases) so we don't fork a
// second mapper.
import { mapMutationError } from './workspace-members.js';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' }); const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json()); const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
// #14: the platform user-management dropdown manages users.role (the
// PLATFORM-level role) only - workspace/org roles are managed in the members
// views. Options are the current model; the legacy 'admin'/'superadmin' strings
// were normalized away. #13 adds 'platform_operator' (cross-org staff).
const PLATFORM_ROLE_OPTIONS = ['user', 'platform_operator', 'platform_admin'];
// Platform staff have cross-org access (no single workspace), so the Workspace
// column shows read-only "Platform (all)" for them. Note utils.isPlatformAdmin
// only covers admin/superadmin; operators are staff here too.
function isPlatformStaffRole(role) {
return role === 'platform_admin' || role === 'superadmin' || role === 'platform_operator';
}
// Short summary of a user's workspace membership for the Users-table cell.
// Platform staff have cross-org access (not per-workspace membership) -> "Platform
// (all)". Otherwise: Unassigned (0), the workspace name (1), or "N workspaces".
function workspaceSummary(u) {
if (isPlatformStaffRole(u.role)) return t('admin.workspace.platform_all');
const count = u.workspace_count || 0;
if (count === 0) return t('admin.workspace.unassigned');
if (count === 1) return esc(u.workspace_name || '');
return t('admin.workspace.multi', { n: count });
}
// Workspace cell: a summary + a "Manage" button that opens the full membership
// modal (add/remove workspaces, set per-workspace role). Manage is offered for
// everyone, including staff (you can grant them explicit memberships too).
function workspaceCell(u) {
return `<td style="padding:8px">
<div style="display:flex;align-items:center;gap:8px">
<span style="color:var(--text-muted);font-size:12px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${workspaceSummary(u)}</span>
<button class="btn btn-secondary btn-sm" type="button" data-ws-manage="${esc(u.id)}">${t('admin.workspace.manage')}</button>
</div>
</td>`;
}
export async function render(container) { export async function render(container) {
const user = JSON.parse(localStorage.getItem('user') || '{}'); const user = JSON.parse(localStorage.getItem('user') || '{}');
if (user.role !== 'superadmin') { if (!isPlatformAdmin(user)) {
container.innerHTML = '<div class="empty-state"><h3>Access Denied</h3><p>Platform admin access required.</p></div>'; container.innerHTML = `<div class="empty-state"><h3>${t('admin.access_denied')}</h3><p>${t('admin.access_denied_desc')}</p></div>`;
return; return;
} }
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div><h1>Platform Admin</h1><div class="subtitle">Superadmin controls - only you can see this</div></div> <div><h1>${t('admin.title')}</h1><div class="subtitle">${t('admin.subtitle')}</div></div>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary" id="adminCreateOrgBtn">${t('admin.create_org.button')}</button>
<button class="btn btn-primary" id="adminAddUserBtn">${t('admin.add_user')}</button>
</div>
</div> </div>
<!-- All Users -->
<div class="settings-section"> <div class="settings-section">
<h3>All Users</h3> <h3>${t('admin.all_users')}</h3>
<div id="allUsersTable"><p style="color:var(--text-muted)">Loading...</p></div> <div id="allUsersTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div> </div>
<!-- Plan Management -->
<div class="settings-section"> <div class="settings-section">
<h3>Subscription Plans</h3> <h3>${t('admin.orgs.title')}</h3>
<div id="plansTable"><p style="color:var(--text-muted)">Loading...</p></div> <p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.orgs.desc')}</p>
<div id="orgsTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div> </div>
<!-- System Info -->
<div class="settings-section"> <div class="settings-section">
<h3>System</h3> <h3>${t('admin.branding.title')}</h3>
<div id="systemInfo"><p style="color:var(--text-muted)">Loading...</p></div> <p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.branding.desc')}</p>
<div id="brandingForm"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div>
<div class="settings-section">
<h3>${t('admin.plans')}</h3>
<div id="plansTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div>
<div class="settings-section">
<h3>${t('admin.system')}</h3>
<div id="systemInfo"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div> </div>
`; `;
// Add User (#10): platform admin provisions a user into ANY workspace. The
// page is platform_admin-gated; the modal opens in picker mode (no fixed
// workspace) so the admin chooses the target org/workspace. The endpoint
// additionally enforces canAdminWorkspace (platform_admin passes everywhere).
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
openAddUserModal(null, {
onSuccess: (result) => {
showToast(t('members.success.user_created', { email: result.email }), 'success');
loadUsers(); loadUsers();
},
mapError: mapMutationError,
});
});
// Create Organization (#35): platform admin provisions a new customer org +
// its first workspace (owned by the admin). The modal reloads on success so
// the new org shows up in the switcher.
document.getElementById('adminCreateOrgBtn')?.addEventListener('click', () => {
openCreateOrgModal({
onSuccess: (result) => showToast(t('admin.create_org.success', { name: result.name }), 'success'),
});
});
loadUsers();
loadOrgs();
loadBranding();
loadPlans(); loadPlans();
loadSystem(); loadSystem();
} }
// #36: list organizations with owner + resource counts; platform admin can
// cascade-delete an org or an individual workspace (type-the-name confirm).
async function loadOrgs() {
const el = document.getElementById('orgsTable');
if (!el) return;
let orgs;
try {
orgs = await api.adminListOrgs();
} catch (err) {
el.innerHTML = `<p style="color:var(--danger)">${esc(err.message || 'Failed to load organizations')}</p>`;
return;
}
if (!orgs.length) {
el.innerHTML = `<p style="color:var(--text-muted)">${t('admin.orgs.empty')}</p>`;
return;
}
el.innerHTML = orgs.map(o => {
const wsRows = (o.workspaces || []).map(w => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-top:1px solid var(--border)">
<div style="font-size:13px">${esc(w.name)}
<span style="color:var(--text-muted);font-size:11px">· ${w.device_count} ${t('admin.orgs.devices')} · ${w.member_count} ${t('admin.orgs.members')}</span>
</div>
<button class="btn btn-danger btn-sm" data-del-ws="${esc(w.id)}" data-ws-name="${esc(w.name)}">${t('admin.orgs.delete_ws')}</button>
</div>`).join('');
return `
<div style="border:1px solid var(--border);border-radius:var(--radius);margin-bottom:10px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg-secondary)">
<div>
<div style="font-weight:600">${esc(o.name)}</div>
<div style="color:var(--text-muted);font-size:11px">
${t('admin.orgs.owner')}: ${esc(o.owner_email || '—')} ·
${o.workspace_count} ${t('admin.orgs.workspaces')} · ${o.device_count} ${t('admin.orgs.devices')} · ${o.member_count} ${t('admin.orgs.members')}
</div>
</div>
<button class="btn btn-danger btn-sm" data-del-org="${esc(o.id)}" data-org-name="${esc(o.name)}">${t('admin.orgs.delete_org')}</button>
</div>
${wsRows}
</div>`;
}).join('');
el.querySelectorAll('[data-del-org]').forEach(btn => btn.addEventListener('click', () => {
const id = btn.dataset.delOrg, name = btn.dataset.orgName;
openTypeToConfirmModal({
title: t('admin.orgs.delete_org_title'),
body: t('admin.orgs.delete_org_body', { name: esc(name) }),
expected: name,
confirmLabel: t('admin.orgs.delete_org'),
onConfirm: async () => {
await api.adminDeleteOrg(id);
showToast(t('admin.orgs.org_deleted', { name }), 'success');
loadOrgs(); loadUsers();
},
});
}));
el.querySelectorAll('[data-del-ws]').forEach(btn => btn.addEventListener('click', () => {
const id = btn.dataset.delWs, name = btn.dataset.wsName;
openTypeToConfirmModal({
title: t('admin.orgs.delete_ws_title'),
body: t('admin.orgs.delete_ws_body', { name: esc(name) }),
expected: name,
confirmLabel: t('admin.orgs.delete_ws'),
onConfirm: async () => {
await api.adminDeleteWorkspace(id);
showToast(t('admin.orgs.ws_deleted', { name }), 'success');
loadOrgs();
},
});
}));
}
// #15: instance-level default branding form (platform default; every workspace
// without its own white-label inherits this, as does the login page).
async function loadBranding() {
const el = document.getElementById('brandingForm');
if (!el) return;
let b = {};
try { b = await api.adminGetBranding(); } catch (e) { el.innerHTML = `<p style="color:var(--danger)">${esc(e.message || 'Failed to load')}</p>`; return; }
const v = (x) => esc(x == null ? '' : x);
el.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.brand_name')}</label><input type="text" id="brBrandName" class="input" placeholder="ScreenTinker" value="${v(b.brand_name)}"></div>
<div class="form-group"><label>${t('admin.branding.primary_color')}</label><input type="text" id="brPrimary" class="input" placeholder="#3B82F6" value="${v(b.primary_color)}"></div>
<div class="form-group"><label>${t('admin.branding.bg_color')}</label><input type="text" id="brBg" class="input" placeholder="#111827" value="${v(b.bg_color)}"></div>
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.logo_url')}</label><input type="text" id="brLogo" class="input" placeholder="https:///logo.png" value="${v(b.logo_url)}"></div>
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.favicon_url')}</label><input type="text" id="brFavicon" class="input" placeholder="https:///favicon.ico" value="${v(b.favicon_url)}"></div>
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.custom_css')}</label><textarea id="brCss" class="input" rows="3" placeholder="/* optional */">${v(b.custom_css)}</textarea></div>
<label style="grid-column:1/-1;display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="checkbox" id="brHide" ${b.hide_branding ? 'checked' : ''}> ${t('admin.branding.hide_branding')}
</label>
</div>
<button class="btn btn-primary btn-sm" id="brSave" style="margin-top:12px">${t('admin.branding.save')}</button>
`;
document.getElementById('brSave').onclick = async () => {
try {
await api.adminSetBranding({
brand_name: document.getElementById('brBrandName').value.trim() || 'ScreenTinker',
primary_color: document.getElementById('brPrimary').value.trim() || null,
bg_color: document.getElementById('brBg').value.trim() || null,
logo_url: document.getElementById('brLogo').value.trim() || null,
favicon_url: document.getElementById('brFavicon').value.trim() || null,
custom_css: document.getElementById('brCss').value.trim() || null,
hide_branding: document.getElementById('brHide').checked,
});
showToast(t('admin.branding.saved'), 'success');
} catch (err) { showToast(err.message, 'error'); }
};
}
async function loadUsers() { async function loadUsers() {
const el = document.getElementById('allUsersTable'); const el = document.getElementById('allUsersTable');
try { try {
const [users, plans] = await Promise.all([API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json())]); const [users, plans] = await Promise.all([
API('/auth/users'),
fetch('/api/subscription/plans').then(r => r.json()),
]);
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
el.innerHTML = ` el.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:13px"> <div class="table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:720px">
<thead><tr style="border-bottom:1px solid var(--border)"> <thead><tr style="border-bottom:1px solid var(--border)">
<th style="padding:8px;text-align:left;color:var(--text-muted)">User</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.user')}</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">Auth</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.auth')}</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">Last Login</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.last_login')}</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">Role</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.role')}</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">Plan</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.plan')}</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">Actions</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.workspace')}</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.actions')}</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
${users.map(u => ` ${users.map(u => `
<tr style="border-bottom:1px solid var(--border)"> <tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px"><div style="font-weight:500">${u.name || u.email}</div><div style="font-size:11px;color:var(--text-muted)">${u.email}</div></td> <td style="padding:8px"><div style="font-weight:500">${u.name || u.email}</div><div style="font-size:11px;color:var(--text-muted)">${u.email}</div></td>
<td style="padding:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span></td> <td style="padding:8px"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span></td>
<td style="padding:8px;font-size:11px;color:var(--text-muted)">${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : 'Never'}</td> <td style="padding:8px;font-size:11px;color:var(--text-muted)">${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')}</td>
<td style="padding:8px"> <td style="padding:8px">
<select class="input" style="width:120px;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}"> <select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}">
<option value="user" ${u.role === 'user' ? 'selected' : ''}>User</option> ${PLATFORM_ROLE_OPTIONS.map(r => `<option value="${r}" ${u.role === r ? 'selected' : ''}>${t('admin.role.' + r)}</option>`).join('')}
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>Admin</option>
<option value="superadmin" ${u.role === 'superadmin' ? 'selected' : ''}>Superadmin</option>
</select> </select>
</td> </td>
<td style="padding:8px"> <td style="padding:8px">
<select class="input" style="width:130px;background:var(--bg-input);font-size:12px;padding:4px" data-plan-user="${u.id}"> <select class="input" style="max-width:130px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-plan-user="${u.id}">
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')} ${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
</select> </select>
</td> </td>
<td style="padding:8px"> ${workspaceCell(u)}
${u.role !== 'superadmin' ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">Remove</button>` : '<span style="color:var(--text-muted);font-size:11px">Owner</span>'} <td style="padding:8px;white-space:nowrap">
${u.auth_provider === 'local' && u.id !== currentUser.id ? `<button class="btn btn-secondary btn-sm" data-reset-pw-user="${u.id}" data-user-email="${u.email}" style="margin-right:4px">${t('admin.reset_password')}</button>` : ''}
${!isPlatformAdmin(u) ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">${t('admin.remove')}</button>` : `<span style="color:var(--text-muted);font-size:11px">${t('admin.owner')}</span>`}
</td> </td>
</tr> </tr>
`).join('')} `).join('')}
</tbody> </tbody>
</table> </table>
<p style="color:var(--text-muted);font-size:11px;margin-top:8px">${users.length} total users</p> </div>
<p style="color:var(--text-muted);font-size:11px;margin-top:8px">${t('admin.total_users', { n: users.length })}</p>
`; `;
// Role change
el.querySelectorAll('[data-role-user]').forEach(select => { el.querySelectorAll('[data-role-user]').forEach(select => {
select.onchange = async () => { select.onchange = async () => {
try { try {
await API(`/auth/users/${select.dataset.roleUser}/role`, { method: 'PUT', body: JSON.stringify({ role: select.value }) }); await API(`/auth/users/${select.dataset.roleUser}/role`, { method: 'PUT', body: JSON.stringify({ role: select.value }) });
showToast('Role updated', 'success'); showToast(t('admin.toast.role_updated'), 'success');
} catch (err) { showToast(err.message, 'error'); loadUsers(); } } catch (err) { showToast(err.message, 'error'); loadUsers(); }
}; };
}); });
// Plan change
el.querySelectorAll('[data-plan-user]').forEach(select => { el.querySelectorAll('[data-plan-user]').forEach(select => {
select.onchange = async () => { select.onchange = async () => {
try { try {
await API('/subscription/assign', { method: 'POST', body: JSON.stringify({ user_id: select.dataset.planUser, plan_id: select.value }) }); await API('/subscription/assign', { method: 'POST', body: JSON.stringify({ user_id: select.dataset.planUser, plan_id: select.value }) });
showToast('Plan updated', 'success'); showToast(t('admin.toast.plan_updated'), 'success');
} catch (err) { showToast(err.message, 'error'); loadUsers(); } } catch (err) { showToast(err.message, 'error'); loadUsers(); }
}; };
}); });
// Delete user // Manage workspaces: open the per-user membership modal (add/remove
// workspaces, set per-workspace role). Refresh the table on close only if
// something changed (the modal calls onClose then).
el.querySelectorAll('[data-ws-manage]').forEach(btn => {
btn.onclick = () => {
const u = users.find(x => x.id === btn.dataset.wsManage);
if (!u) return;
openManageWorkspacesModal(u, { onClose: () => loadUsers() });
};
});
// Reset password handlers
el.querySelectorAll('[data-reset-pw-user]').forEach(btn => {
btn.onclick = async () => {
const email = btn.dataset.userEmail;
const pw = prompt(t('admin.prompt_reset_password', { email }));
if (pw === null) return;
if (pw.length < 8) { showToast(t('admin.toast.password_min_8'), 'error'); return; }
try {
await api.resetUserPassword(btn.dataset.resetPwUser, pw);
showToast(t('admin.toast.password_reset'), 'success');
} catch (err) { showToast(err.message, 'error'); }
};
});
el.querySelectorAll('[data-delete-user]').forEach(btn => { el.querySelectorAll('[data-delete-user]').forEach(btn => {
let confirming = false; let confirming = false;
btn.onclick = async () => { btn.onclick = async () => {
if (confirming) { if (confirming) {
try { await api.deleteUser(btn.dataset.deleteUser); showToast('User removed', 'success'); loadUsers(); } try { await api.deleteUser(btn.dataset.deleteUser); showToast(t('admin.toast.user_removed'), 'success'); loadUsers(); }
catch (err) { showToast(err.message, 'error'); } catch (err) { showToast(err.message, 'error'); }
return; return;
} }
confirming = true; btn.textContent = 'Confirm?'; btn.style.background = 'var(--danger)'; btn.style.color = 'white'; confirming = true; btn.textContent = t('admin.confirm'); btn.style.background = 'var(--danger)'; btn.style.color = 'white';
setTimeout(() => { confirming = false; btn.textContent = 'Remove'; btn.style.background = ''; btn.style.color = ''; }, 3000); setTimeout(() => { confirming = false; btn.textContent = t('admin.remove'); btn.style.background = ''; btn.style.color = ''; }, 3000);
}; };
}); });
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; } } catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
@ -126,26 +346,28 @@ async function loadPlans() {
try { try {
const plans = await fetch('/api/subscription/plans').then(r => r.json()); const plans = await fetch('/api/subscription/plans').then(r => r.json());
el.innerHTML = ` el.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:13px"> <div class="table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:500px">
<thead><tr style="border-bottom:1px solid var(--border)"> <thead><tr style="border-bottom:1px solid var(--border)">
<th style="padding:8px;text-align:left;color:var(--text-muted)">Plan</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">${t('admin.col.plan')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Devices</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.devices')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Storage</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.storage')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Monthly</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.monthly')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Yearly</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.yearly')}</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
${plans.map(p => ` ${plans.map(p => `
<tr style="border-bottom:1px solid var(--border)"> <tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px;font-weight:500">${p.display_name}</td> <td style="padding:8px;font-weight:500">${p.display_name}</td>
<td style="padding:8px;text-align:right">${p.max_devices === -1 ? 'Unlimited' : p.max_devices}</td> <td style="padding:8px;text-align:right">${p.max_devices === -1 ? t('admin.unlimited') : p.max_devices}</td>
<td style="padding:8px;text-align:right">${p.max_storage_mb === -1 ? 'Unlimited' : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'}</td> <td style="padding:8px;text-align:right">${p.max_storage_mb === -1 ? t('admin.unlimited') : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'}</td>
<td style="padding:8px;text-align:right">${p.price_monthly > 0 ? '$'+p.price_monthly : 'Free'}</td> <td style="padding:8px;text-align:right">${p.price_monthly > 0 ? '$'+p.price_monthly : t('admin.free')}</td>
<td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}</td> <td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}</td>
</tr> </tr>
`).join('')} `).join('')}
</tbody> </tbody>
</table> </table>
</div>
`; `;
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; } } catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
} }
@ -157,12 +379,12 @@ async function loadSystem() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
el.innerHTML = ` el.innerHTML = `
<div class="info-grid"> <div class="info-grid">
<div class="info-card"><div class="info-card-label">Version</div><div class="info-card-value small">${version.version}</div></div> <div class="info-card"><div class="info-card-label">${t('admin.version')}</div><div class="info-card-value small">${version.version}</div></div>
<div class="info-card"><div class="info-card-label">Frontend Hash</div><div class="info-card-value small">${version.hash}</div></div> <div class="info-card"><div class="info-card-label">${t('admin.frontend_hash')}</div><div class="info-card-value small">${version.hash}</div></div>
</div> </div>
<div style="display:flex;gap:8px;margin-top:16px"> <div style="display:flex;gap:8px;margin-top:16px">
<a href="/api/status/backup?token=${token}" class="btn btn-secondary btn-sm" style="text-decoration:none">Download DB Backup</a> <a href="/api/status/backup?token=${token}" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.download_db_backup')}</a>
<a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">Server Status</a> <a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.server_status')}</a>
</div> </div>
`; `;
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; } } catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }

View file

@ -1,16 +1,17 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
import { t } from '../i18n.js';
export async function render(container) { export async function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>Subscription</h1> <h1>${t('billing.title')}</h1>
<div class="subtitle">Manage your plan and billing</div> <div class="subtitle">${t('billing.subtitle')}</div>
</div> </div>
</div> </div>
<div id="billingContent"><div class="empty-state"><h3>Loading...</h3></div></div> <div id="billingContent"><div class="empty-state"><h3>${t('common.loading')}</h3></div></div>
`; `;
try { try {
@ -22,27 +23,26 @@ export async function render(container) {
const content = document.getElementById('billingContent'); const content = document.getElementById('billingContent');
content.innerHTML = ` content.innerHTML = `
<!-- Current Plan -->
<div class="settings-section"> <div class="settings-section">
<h3>Current Plan</h3> <h3>${t('billing.current_plan')}</h3>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:16px"> <div style="display:flex;align-items:center;gap:16px;margin-bottom:16px">
<div style="font-size:28px;font-weight:700;color:var(--accent)">${subData.plan.display_name}</div> <div style="font-size:28px;font-weight:700;color:var(--accent)">${subData.plan.display_name}</div>
${subData.self_hosted ? '<span style="background:var(--success-dim);color:var(--success);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">Self-Hosted</span>' : ''} ${subData.self_hosted ? `<span style="background:var(--success-dim);color:var(--success);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">${t('billing.self_hosted')}</span>` : ''}
${subData.trial?.active ? `<span style="background:var(--warning-dim);color:var(--warning);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">Trial - ${subData.trial.days_left} days left</span>` : ''} ${subData.trial?.active ? `<span style="background:var(--warning-dim);color:var(--warning);padding:4px 10px;border-radius:12px;font-size:11px;font-weight:500">${t('billing.trial_days_left', { n: subData.trial.days_left })}</span>` : ''}
</div> </div>
${subData.trial?.active ? ` ${subData.trial?.active ? `
<div style="background:var(--bg-secondary);border:1px solid var(--warning);border-radius:var(--radius);padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:12px"> <div style="background:var(--bg-secondary);border:1px solid var(--warning);border-radius:var(--radius);padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:12px">
<span style="font-size:20px">&#9201;</span> <span style="font-size:20px">&#9201;</span>
<div> <div>
<div style="font-size:13px;font-weight:500">Your ${subData.trial.plan?.charAt(0).toUpperCase() + subData.trial.plan?.slice(1)} trial ends in ${subData.trial.days_left} days</div> <div style="font-size:13px;font-weight:500">${t('billing.trial_ends', { plan: (subData.trial.plan?.charAt(0).toUpperCase() + subData.trial.plan?.slice(1)) || '', n: subData.trial.days_left })}</div>
<div style="font-size:12px;color:var(--text-muted)">After the trial, you'll be moved to the Free plan (1 device). Upgrade now to keep all your devices and features.</div> <div style="font-size:12px;color:var(--text-muted)">${t('billing.trial_after')}</div>
</div> </div>
</div> </div>
` : ''} ` : ''}
<div class="info-grid" style="margin-bottom:0"> <div class="info-grid" style="margin-bottom:0">
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Devices</div> <div class="info-card-label">${t('billing.devices')}</div>
<div class="info-card-value">${subData.usage.devices} <span style="font-size:14px;color:var(--text-secondary)">/ ${subData.plan.max_devices === -1 ? 'Unlimited' : subData.plan.max_devices}</span></div> <div class="info-card-value">${subData.usage.devices} <span style="font-size:14px;color:var(--text-secondary)">/ ${subData.plan.max_devices === -1 ? t('billing.unlimited') : subData.plan.max_devices}</span></div>
${subData.plan.max_devices > 0 ? ` ${subData.plan.max_devices > 0 ? `
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-bar-fill ${subData.usage.devices / subData.plan.max_devices > 0.8 ? 'warning' : 'success'}" <div class="progress-bar-fill ${subData.usage.devices / subData.plan.max_devices > 0.8 ? 'warning' : 'success'}"
@ -50,8 +50,8 @@ export async function render(container) {
</div>` : ''} </div>` : ''}
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Storage</div> <div class="info-card-label">${t('billing.storage')}</div>
<div class="info-card-value small">${subData.usage.storage_mb} MB <span style="color:var(--text-secondary)">/ ${subData.plan.max_storage_mb === -1 ? 'Unlimited' : subData.plan.max_storage_mb + ' MB'}</span></div> <div class="info-card-value small">${subData.usage.storage_mb} MB <span style="color:var(--text-secondary)">/ ${subData.plan.max_storage_mb === -1 ? t('billing.unlimited') : subData.plan.max_storage_mb + ' MB'}</span></div>
${subData.plan.max_storage_mb > 0 ? ` ${subData.plan.max_storage_mb > 0 ? `
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-bar-fill ${subData.usage.storage_mb / subData.plan.max_storage_mb > 0.8 ? 'warning' : 'success'}" <div class="progress-bar-fill ${subData.usage.storage_mb / subData.plan.max_storage_mb > 0.8 ? 'warning' : 'success'}"
@ -59,51 +59,50 @@ export async function render(container) {
</div>` : ''} </div>` : ''}
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Features</div> <div class="info-card-label">${t('billing.features')}</div>
<div style="font-size:13px;margin-top:4px"> <div style="font-size:13px;margin-top:4px">
${subData.plan.remote_control ? '<div style="color:var(--success)">&#10003; Remote Control</div>' : '<div style="color:var(--text-muted)">&#10007; Remote Control</div>'} ${subData.plan.remote_control ? `<div style="color:var(--success)">&#10003; ${t('billing.feat.remote_control')}</div>` : `<div style="color:var(--text-muted)">&#10007; ${t('billing.feat.remote_control')}</div>`}
${subData.plan.remote_url ? '<div style="color:var(--success)">&#10003; Remote URLs</div>' : '<div style="color:var(--text-muted)">&#10007; Remote URLs</div>'} ${subData.plan.remote_url ? `<div style="color:var(--success)">&#10003; ${t('billing.feat.remote_urls')}</div>` : `<div style="color:var(--text-muted)">&#10007; ${t('billing.feat.remote_urls')}</div>`}
${subData.plan.priority_support ? '<div style="color:var(--success)">&#10003; Priority Support</div>' : '<div style="color:var(--text-muted)">&#10007; Priority Support</div>'} ${subData.plan.priority_support ? `<div style="color:var(--success)">&#10003; ${t('billing.feat.priority_support')}</div>` : `<div style="color:var(--text-muted)">&#10007; ${t('billing.feat.priority_support')}</div>`}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Plans -->
<div class="settings-section"> <div class="settings-section">
<h3>Available Plans</h3> <h3>${t('billing.available_plans')}</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(240px, 1fr));gap:16px"> <div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(240px, 1fr));gap:16px">
${plans.map(p => ` ${plans.map(p => `
<div style="background:var(--bg-secondary);border:${p.id === subData.plan.id ? '2px solid var(--accent)' : '1px solid var(--border)'};border-radius:var(--radius-lg);padding:20px;position:relative"> <div style="background:var(--bg-secondary);border:${p.id === subData.plan.id ? '2px solid var(--accent)' : '1px solid var(--border)'};border-radius:var(--radius-lg);padding:20px;position:relative">
${p.id === subData.plan.id ? '<div style="position:absolute;top:-10px;right:12px;background:var(--accent);color:white;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:500">Current</div>' : ''} ${p.id === subData.plan.id ? `<div style="position:absolute;top:-10px;right:12px;background:var(--accent);color:white;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:500">${t('billing.current')}</div>` : ''}
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${p.display_name}</div> <div style="font-size:18px;font-weight:700;margin-bottom:4px">${p.display_name}</div>
<div style="font-size:24px;font-weight:700;color:var(--accent);margin-bottom:12px"> <div style="font-size:24px;font-weight:700;color:var(--accent);margin-bottom:12px">
${p.price_monthly > 0 ? `$${p.price_monthly}<span style="font-size:13px;color:var(--text-secondary);font-weight:400">/mo</span>` : 'Free'} ${p.price_monthly > 0 ? `$${p.price_monthly}<span style="font-size:13px;color:var(--text-secondary);font-weight:400">${t('billing.per_month')}</span>` : t('billing.free')}
</div> </div>
<div style="font-size:13px;color:var(--text-secondary);line-height:2"> <div style="font-size:13px;color:var(--text-secondary);line-height:2">
<div>${p.max_devices === -1 ? 'Unlimited' : p.max_devices} devices</div> <div>${p.max_devices === -1 ? t('billing.unlimited') : p.max_devices} ${t('billing.devices_lc')}</div>
<div>${p.max_storage_mb === -1 ? 'Unlimited' : (p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024) + ' GB' : p.max_storage_mb + ' MB')} storage</div> <div>${p.max_storage_mb === -1 ? t('billing.unlimited') : (p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024) + ' GB' : p.max_storage_mb + ' MB')} ${t('billing.storage_lc')}</div>
<div>${p.remote_control ? '&#10003;' : '&#10007;'} Remote Control</div> <div>${p.remote_control ? '&#10003;' : '&#10007;'} ${t('billing.feat.remote_control')}</div>
<div>${p.remote_url ? '&#10003;' : '&#10007;'} Remote URLs</div> <div>${p.remote_url ? '&#10003;' : '&#10007;'} ${t('billing.feat.remote_urls')}</div>
<div>${p.priority_support ? '&#10003;' : '&#10007;'} Priority Support</div> <div>${p.priority_support ? '&#10003;' : '&#10007;'} ${t('billing.feat.priority_support')}</div>
</div> </div>
${p.price_yearly > 0 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:8px">or $${p.price_yearly}/year (save ${Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100)}%)</div>` : ''} ${p.price_yearly > 0 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:8px">${t('billing.yearly_save', { price: p.price_yearly, pct: Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100) })}</div>` : ''}
${!subData.self_hosted && p.price_monthly > 0 && p.id !== subData.plan.id ? ` ${!subData.self_hosted && p.price_monthly > 0 && p.id !== subData.plan.id ? `
<div style="margin-top:12px;display:flex;gap:6px"> <div style="margin-top:12px;display:flex;gap:6px">
<button class="btn btn-primary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','monthly')">Monthly</button> <button class="btn btn-primary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','monthly')">${t('billing.monthly')}</button>
${p.price_yearly > 0 ? `<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','yearly')">Yearly</button>` : ''} ${p.price_yearly > 0 ? `<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','yearly')">${t('billing.yearly')}</button>` : ''}
</div> </div>
` : ''} ` : ''}
${!subData.self_hosted && p.id === subData.plan.id && subData.subscription?.stripe_subscription_id ? ` ${!subData.self_hosted && p.id === subData.plan.id && subData.subscription?.stripe_subscription_id ? `
<button class="btn btn-secondary btn-sm" style="width:100%;margin-top:12px" onclick="window._manageSubscription()">Manage Subscription</button> <button class="btn btn-secondary btn-sm" style="width:100%;margin-top:12px" onclick="window._manageSubscription()">${t('billing.manage_subscription')}</button>
` : ''} ` : ''}
</div> </div>
`).join('')} `).join('')}
</div> </div>
${subData.self_hosted ? '<p style="color:var(--text-muted);font-size:12px;margin-top:12px">Self-hosted mode: plans can be assigned by admins without billing.</p>' : ''} ${subData.self_hosted ? `<p style="color:var(--text-muted);font-size:12px;margin-top:12px">${t('billing.self_hosted_note')}</p>` : ''}
</div> </div>
`; `;
// Checkout handler
window._checkout = async (planId, interval) => { window._checkout = async (planId, interval) => {
try { try {
const res = await fetch('/api/stripe/checkout', { const res = await fetch('/api/stripe/checkout', {
@ -115,11 +114,10 @@ export async function render(container) {
if (data.error) { showToast(data.error, 'error'); return; } if (data.error) { showToast(data.error, 'error'); return; }
if (data.url) window.location.href = data.url; if (data.url) window.location.href = data.url;
} catch (err) { } catch (err) {
showToast('Failed to start checkout: ' + err.message, 'error'); showToast(t('billing.toast.checkout_failed', { error: err.message }), 'error');
} }
}; };
// Manage subscription handler (Stripe Customer Portal)
window._manageSubscription = async () => { window._manageSubscription = async () => {
try { try {
const res = await fetch('/api/stripe/portal', { const res = await fetch('/api/stripe/portal', {
@ -130,18 +128,17 @@ export async function render(container) {
if (data.error) { showToast(data.error, 'error'); return; } if (data.error) { showToast(data.error, 'error'); return; }
if (data.url) window.location.href = data.url; if (data.url) window.location.href = data.url;
} catch (err) { } catch (err) {
showToast('Failed to open billing portal: ' + err.message, 'error'); showToast(t('billing.toast.portal_failed', { error: err.message }), 'error');
} }
}; };
// Check for payment success/cancel in URL
if (window.location.hash.includes('payment=success')) { if (window.location.hash.includes('payment=success')) {
showToast('Payment successful! Your plan has been upgraded.', 'success'); showToast(t('billing.toast.payment_success'), 'success');
window.location.hash = '#/billing'; window.location.hash = '#/billing';
} }
} catch (err) { } catch (err) {
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>Failed to load</h3><p>${esc(err.message)}</p></div>`; document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>${t('billing.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
} }
} }

View file

@ -1,6 +1,7 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
import { t } from '../i18n.js';
function formatFileSize(bytes) { function formatFileSize(bytes) {
if (!bytes) return '--'; if (!bytes) return '--';
@ -10,30 +11,60 @@ function formatFileSize(bytes) {
return `${bytes} B`; return `${bytes} B`;
} }
// Lazy-load authenticated thumbnails/previews. A plain <img> can't send the
// Bearer token, and the content thumbnail/file endpoints require auth (or a
// playlist/widget reference) - so a just-uploaded item's thumbnail 403'd. We fetch
// with the token and swap in an object URL. IntersectionObserver keeps it lazy so
// we stay under the /api/content rate limit; the object URL is revoked after load.
let _authImgObserver = null;
function loadAuthImage(img) {
const url = img.dataset.authSrc;
if (!url) return;
delete img.dataset.authSrc;
fetch(url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })
.then(r => (r.ok ? r.blob() : Promise.reject(r.status)))
.then(blob => {
const obj = URL.createObjectURL(blob);
img.addEventListener('load', () => URL.revokeObjectURL(obj), { once: true });
img.src = obj;
})
.catch(() => { img.style.opacity = '0.25'; });
}
function hydrateAuthImages(root) {
const imgs = root.querySelectorAll('img[data-auth-src]');
if (typeof IntersectionObserver === 'undefined') { imgs.forEach(loadAuthImage); return; }
if (!_authImgObserver) {
_authImgObserver = new IntersectionObserver((entries, obs) => {
for (const e of entries) if (e.isIntersecting) { obs.unobserve(e.target); loadAuthImage(e.target); }
}, { rootMargin: '300px' });
}
imgs.forEach(img => _authImgObserver.observe(img));
}
export function render(container) { export function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>Content Library <span class="help-tip" data-tip="Upload videos and images here. Select multiple files for bulk upload. Use Remote URL to stream from external sources. Click a thumbnail to preview.">?</span></h1> <h1>${t('content.title')} <span class="help-tip" data-tip="${t('content.help_tip')}">?</span></h1>
<div class="subtitle">Upload and manage your media files</div> <div class="subtitle">${t('content.subtitle')}</div>
</div> </div>
</div> </div>
<div style="display:flex;gap:16px;margin-bottom:24px"> <div class="content-toolbar" style="display:flex;gap:16px;margin-bottom:24px">
<div class="upload-area" id="uploadArea" style="flex:1;margin-bottom:0"> <div class="upload-area" id="uploadArea" style="flex:1;margin-bottom:0">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/> <polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/> <line x1="12" y1="3" x2="12" y2="15"/>
</svg> </svg>
<p>Drop files here or click to upload</p> <p>${t('content.drop')}</p>
<p class="upload-hint">Supports MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP</p> <p class="upload-hint">${t('content.upload_hint')}</p>
<input type="file" id="fileInput" style="display:none" multiple accept="video/*,image/*"> <input type="file" id="fileInput" style="display:none" multiple accept="video/*,image/*">
<div class="upload-progress" id="uploadProgress" style="display:none"> <div class="upload-progress" id="uploadProgress" style="display:none">
<div class="upload-progress-bar"> <div class="upload-progress-bar">
<div class="upload-progress-fill" id="uploadProgressFill" style="width:0%"></div> <div class="upload-progress-fill" id="uploadProgressFill" style="width:0%"></div>
</div> </div>
<p style="font-size:12px;color:var(--text-secondary);margin-top:6px" id="uploadProgressText">Uploading...</p> <p style="font-size:12px;color:var(--text-secondary);margin-top:6px" id="uploadProgressText">${t('content.upload_progress')}</p>
</div> </div>
</div> </div>
<div style="width:320px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;flex-direction:column;gap:12px"> <div style="width:320px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;flex-direction:column;gap:12px">
@ -42,18 +73,18 @@ export function render(container) {
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg> </svg>
Remote URL ${t('content.remote_url')}
</div> </div>
<p style="font-size:12px;color:var(--text-muted)">Stream directly from a URL. Saves local bandwidth.</p> <p style="font-size:12px;color:var(--text-muted)">${t('content.remote_desc')}</p>
<input type="text" id="remoteUrlInput" class="input" placeholder="https://example.com/video.mp4"> <input type="text" id="remoteUrlInput" class="input" placeholder="${t('content.remote_url_placeholder')}">
<input type="text" id="remoteNameInput" class="input" placeholder="Display name (optional)"> <input type="text" id="remoteNameInput" class="input" placeholder="${t('content.remote_name_placeholder')}">
<select id="remoteMimeType" class="input" style="background:var(--bg-input)"> <select id="remoteMimeType" class="input" style="background:var(--bg-input)">
<option value="video/mp4">Video (MP4)</option> <option value="video/mp4">${t('content.mime.video_mp4')}</option>
<option value="video/webm">Video (WebM)</option> <option value="video/webm">${t('content.mime.video_webm')}</option>
<option value="image/jpeg">Image (JPEG)</option> <option value="image/jpeg">${t('content.mime.image_jpeg')}</option>
<option value="image/png">Image (PNG)</option> <option value="image/png">${t('content.mime.image_png')}</option>
</select> </select>
<button class="btn btn-primary" id="addRemoteBtn">Add Remote URL</button> <button class="btn btn-primary" id="addRemoteBtn">${t('content.remote_add_btn')}</button>
</div> </div>
<div style="width:320px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;flex-direction:column;gap:12px"> <div style="width:320px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;gap:8px;color:var(--text-primary);font-weight:500"> <div style="display:flex;align-items:center;gap:8px;color:var(--text-primary);font-weight:500">
@ -61,25 +92,24 @@ export function render(container) {
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/> <path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/>
<polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02"/> <polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02"/>
</svg> </svg>
YouTube ${t('content.youtube')}
</div> </div>
<p style="font-size:12px;color:var(--text-muted)">Embed a YouTube video on your displays.</p> <p style="font-size:12px;color:var(--text-muted)">${t('content.youtube_desc')}</p>
<input type="text" id="youtubeUrlInput" class="input" placeholder="https://youtube.com/watch?v=..."> <input type="text" id="youtubeUrlInput" class="input" placeholder="${t('content.youtube_url_placeholder')}">
<input type="text" id="youtubeNameInput" class="input" placeholder="Display name (optional)"> <input type="text" id="youtubeNameInput" class="input" placeholder="${t('content.youtube_name_placeholder')}">
<button class="btn btn-primary" id="addYoutubeBtn">Add YouTube Video</button> <button class="btn btn-primary" id="addYoutubeBtn">${t('content.youtube_add_btn')}</button>
</div> </div>
</div> </div>
</div> </div>
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap"> <div style="display:flex;gap:12px;margin-bottom:12px;align-items:center;flex-wrap:wrap">
<input type="text" id="contentSearch" class="input" placeholder="Search content..." style="width:250px"> <input type="text" id="contentSearch" class="input" placeholder="${t('content.search_placeholder')}" style="max-width:250px;width:100%">
<select id="folderFilter" class="input" style="width:180px;background:var(--bg-input)"> <button class="btn btn-secondary btn-sm" id="newFolderBtn">${t('content.new_folder_btn')}</button>
<option value="">All Folders</option>
</select>
<button class="btn btn-secondary btn-sm" id="newFolderBtn">+ New Folder</button>
</div> </div>
<div id="folderBreadcrumb" style="display:flex;gap:6px;align-items:center;margin-bottom:12px;font-size:13px;flex-wrap:wrap"></div>
<div id="folderGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:16px"></div>
<div class="content-grid" id="contentGrid"> <div class="content-grid" id="contentGrid">
<div class="empty-state" style="grid-column:1/-1"><h3>Loading...</h3></div> <div class="empty-state" style="grid-column:1/-1"><h3>${t('common.loading')}</h3></div>
</div> </div>
`; `;
@ -115,12 +145,12 @@ export function render(container) {
const name = document.getElementById('remoteNameInput').value.trim(); const name = document.getElementById('remoteNameInput').value.trim();
const mimeType = document.getElementById('remoteMimeType').value; const mimeType = document.getElementById('remoteMimeType').value;
if (!url) { if (!url) {
showToast('Enter a URL', 'error'); showToast(t('content.error_enter_url'), 'error');
return; return;
} }
try { try {
await api.addRemoteContent(url, name, mimeType); await api.addRemoteContent(url, name, mimeType);
showToast('Remote content added', 'success'); showToast(t('content.toast.remote_added'), 'success');
document.getElementById('remoteUrlInput').value = ''; document.getElementById('remoteUrlInput').value = '';
document.getElementById('remoteNameInput').value = ''; document.getElementById('remoteNameInput').value = '';
loadContent(); loadContent();
@ -134,12 +164,12 @@ export function render(container) {
const url = document.getElementById('youtubeUrlInput').value.trim(); const url = document.getElementById('youtubeUrlInput').value.trim();
const name = document.getElementById('youtubeNameInput').value.trim(); const name = document.getElementById('youtubeNameInput').value.trim();
if (!url) { if (!url) {
showToast('Enter a YouTube URL', 'error'); showToast(t('content.error_enter_youtube_url'), 'error');
return; return;
} }
try { try {
await api.addYoutubeContent(url, name); await api.addYoutubeContent(url, name);
showToast('YouTube video added', 'success'); showToast(t('content.toast.youtube_added'), 'success');
document.getElementById('youtubeUrlInput').value = ''; document.getElementById('youtubeUrlInput').value = '';
document.getElementById('youtubeNameInput').value = ''; document.getElementById('youtubeNameInput').value = '';
loadContent(); loadContent();
@ -148,36 +178,41 @@ export function render(container) {
} }
}); });
// Content search + folder filter // Content search filters items currently shown in the grid.
function filterContent() { function filterContent() {
const q = document.getElementById('contentSearch').value.toLowerCase(); const q = document.getElementById('contentSearch').value.toLowerCase();
const folder = document.getElementById('folderFilter').value;
document.querySelectorAll('.content-item').forEach(item => { document.querySelectorAll('.content-item').forEach(item => {
const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || ''; const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || '';
const itemFolder = item.dataset.folder || ''; item.style.display = (!q || name.includes(q)) ? '' : 'none';
const matchSearch = !q || name.includes(q); });
const matchFolder = !folder || itemFolder === folder; document.querySelectorAll('.folder-card').forEach(card => {
item.style.display = (matchSearch && matchFolder) ? '' : 'none'; const name = card.dataset.name?.toLowerCase() || '';
card.style.display = (!q || name.includes(q)) ? '' : 'none';
}); });
} }
document.getElementById('contentSearch').oninput = filterContent; document.getElementById('contentSearch').oninput = filterContent;
document.getElementById('folderFilter').onchange = filterContent;
// New folder // Create folder in the current folder.
document.getElementById('newFolderBtn').onclick = () => { document.getElementById('newFolderBtn').onclick = async () => {
const name = prompt('Folder name:'); const name = prompt(t('content.prompt_folder_name'));
if (name) { if (!name || !name.trim()) return;
// Just add to the dropdown - folders are created when content is moved into them try {
const opt = document.createElement('option'); await api.createFolder(name.trim(), state.currentFolderId);
opt.value = name; opt.textContent = name; showToast(t('content.toast.folder_created_named', { name }), 'success');
document.getElementById('folderFilter').appendChild(opt); loadContent();
showToast(`Folder "${name}" created. Edit content to move it here.`, 'info'); } catch (err) { showToast(err.message, 'error'); }
}
}; };
loadContent(); loadContent();
} }
// View state — current folder navigation. Lives at module scope so the back button
// and other handlers can read it without threading it through every callback.
const state = {
currentFolderId: null, // null = root
folders: [], // all folders for this user (flat tree)
};
async function handleFiles(files) { async function handleFiles(files) {
const progress = document.getElementById('uploadProgress'); const progress = document.getElementById('uploadProgress');
const progressFill = document.getElementById('uploadProgressFill'); const progressFill = document.getElementById('uploadProgressFill');
@ -186,16 +221,16 @@ async function handleFiles(files) {
for (const file of files) { for (const file of files) {
progress.style.display = 'block'; progress.style.display = 'block';
progressFill.style.width = '0%'; progressFill.style.width = '0%';
progressText.textContent = `Uploading ${file.name}...`; progressText.textContent = t('content.upload_progress_named', { name: file.name });
try { try {
await api.uploadContent(file, (pct) => { await api.uploadContent(file, (pct) => {
progressFill.style.width = pct + '%'; progressFill.style.width = pct + '%';
progressText.textContent = `Uploading ${file.name}... ${pct}%`; progressText.textContent = t('content.upload_progress_named_pct', { name: file.name, pct });
}); });
showToast(`${file.name} uploaded successfully`, 'success'); showToast(t('content.toast.uploaded_named', { name: file.name }), 'success');
} catch (err) { } catch (err) {
showToast(`Failed to upload ${file.name}: ${err.message}`, 'error'); showToast(t('content.toast.upload_failed_named', { name: file.name, error: err.message }), 'error');
} }
} }
@ -205,30 +240,149 @@ async function handleFiles(files) {
async function loadContent() { async function loadContent() {
const grid = document.getElementById('contentGrid'); const grid = document.getElementById('contentGrid');
if (!grid) return; const folderGrid = document.getElementById('folderGrid');
const breadcrumb = document.getElementById('folderBreadcrumb');
if (!grid || !folderGrid || !breadcrumb) return;
try { try {
const content = await api.getContent(); const [content, folders] = await Promise.all([
api.getContent(state.currentFolderId === null ? null : state.currentFolderId),
api.getFolders(),
]);
state.folders = folders;
// Breadcrumb path: walk parent_id chain from current folder up to root.
const folderById = new Map(folders.map(f => [f.id, f]));
const path = [];
let cursor = state.currentFolderId ? folderById.get(state.currentFolderId) : null;
while (cursor) {
path.unshift(cursor);
cursor = cursor.parent_id ? folderById.get(cursor.parent_id) : null;
}
breadcrumb.innerHTML = `
<a href="#" data-folder-nav="" style="color:var(--text-secondary);text-decoration:none">${t('content.breadcrumb_root')}</a>
${path.map(f => `
<span style="color:var(--text-muted)">/</span>
<a href="#" data-folder-nav="${f.id}" style="color:var(--text-primary);text-decoration:none">${esc(f.name)}</a>
`).join('')}
${state.currentFolderId ? `
<button class="btn btn-secondary btn-sm" id="renameFolderBtn" style="margin-left:auto">${t('content.rename_btn')}</button>
<button class="btn btn-danger btn-sm" id="deleteFolderBtn">${t('content.delete_folder_btn')}</button>
` : ''}
`;
breadcrumb.querySelectorAll('[data-folder-nav]').forEach(a => {
a.addEventListener('click', (e) => {
e.preventDefault();
const id = a.dataset.folderNav;
state.currentFolderId = id || null;
loadContent();
});
// Make breadcrumb segments drop targets too — otherwise the only way to move
// a file out of a folder is via the edit modal. Dropping on "All Content"
// moves to root; dropping on a parent name moves there.
a.addEventListener('dragover', (e) => {
if (!e.dataTransfer.types.includes('text/content-id')) return;
e.preventDefault();
a.style.background = 'var(--primary)';
a.style.color = '#fff';
a.style.padding = '2px 8px';
a.style.borderRadius = '4px';
});
a.addEventListener('dragleave', () => {
a.style.background = '';
a.style.color = '';
a.style.padding = '';
a.style.borderRadius = '';
});
a.addEventListener('drop', async (e) => {
e.preventDefault();
a.style.background = ''; a.style.color = ''; a.style.padding = ''; a.style.borderRadius = '';
const contentId = e.dataTransfer.getData('text/content-id');
if (!contentId) return;
const targetFolderId = a.dataset.folderNav || null; // empty string = root
try {
await api.moveContent(contentId, targetFolderId);
showToast(targetFolderId ? t('content.toast.moved') : t('content.toast.moved_to_root'), 'success');
loadContent();
} catch (err) { showToast(err.message, 'error'); }
});
});
const renameBtn = breadcrumb.querySelector('#renameFolderBtn');
if (renameBtn) renameBtn.onclick = async () => {
const current = folderById.get(state.currentFolderId);
const name = prompt(t('content.prompt_rename_folder'), current?.name || '');
if (!name || !name.trim() || name === current?.name) return;
try {
await api.renameFolder(state.currentFolderId, name.trim());
showToast(t('content.toast.folder_renamed'), 'success');
loadContent();
} catch (err) { showToast(err.message, 'error'); }
};
const deleteBtn = breadcrumb.querySelector('#deleteFolderBtn');
if (deleteBtn) deleteBtn.onclick = async () => {
if (!confirm(t('content.confirm_delete_folder'))) return;
try {
const parentId = folderById.get(state.currentFolderId)?.parent_id || null;
await api.deleteFolder(state.currentFolderId);
showToast(t('content.toast.folder_deleted'), 'success');
state.currentFolderId = parentId;
loadContent();
} catch (err) { showToast(err.message, 'error'); }
};
// Render subfolders of the current folder.
const subfolders = folders.filter(f => (f.parent_id || null) === state.currentFolderId);
folderGrid.innerHTML = subfolders.map(f => `
<div class="folder-card" data-folder-id="${f.id}" data-name="${esc(f.name)}"
style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);padding:14px;cursor:pointer;display:flex;align-items:center;gap:10px"
data-drop-folder="${f.id}">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<div style="font-size:14px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(f.name)}</div>
</div>
`).join('');
folderGrid.querySelectorAll('.folder-card').forEach(card => {
card.addEventListener('click', () => {
state.currentFolderId = card.dataset.folderId;
loadContent();
});
// Drop target for dragging content items into this folder.
card.addEventListener('dragover', (e) => { e.preventDefault(); card.style.outline = '2px solid var(--primary)'; });
card.addEventListener('dragleave', () => { card.style.outline = ''; });
card.addEventListener('drop', async (e) => {
e.preventDefault();
card.style.outline = '';
const contentId = e.dataTransfer.getData('text/content-id');
if (!contentId) return;
try {
await api.moveContent(contentId, card.dataset.folderId);
showToast(t('content.toast.moved'), 'success');
loadContent();
} catch (err) { showToast(err.message, 'error'); }
});
});
if (!content.length) { if (!content.length) {
grid.innerHTML = ` grid.innerHTML = subfolders.length ? '' : `
<div class="empty-state" style="grid-column:1/-1"> <div class="empty-state" style="grid-column:1/-1">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/> <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
<polyline points="13 2 13 9 20 9"/> <polyline points="13 2 13 9 20 9"/>
</svg> </svg>
<h3>No content yet</h3> <h3>${state.currentFolderId ? t('content.empty_folder_title') : t('content.no_content')}</h3>
<p>Upload videos and images to get started.</p> <p>${state.currentFolderId ? t('content.empty_folder_desc') : t('content.no_content_desc')}</p>
</div> </div>
`; `;
return; return;
} }
grid.innerHTML = content.map(c => ` grid.innerHTML = content.map(c => `
<div class="content-item" data-content-id="${c.id}" data-folder="${c.folder || ''}"> <div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${c.folder || ''}">
<div class="content-item-preview"> <div class="content-item-preview">
${c.mime_type === 'video/youtube' ${c.mime_type === 'video/youtube'
? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center"> ? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center">
<img src="${c.thumbnail_path}" alt="${c.filename}" loading="lazy" style="width:100%;height:100%;object-fit:cover"> <img src="${c.thumbnail_path}" alt="${esc(c.filename)}" loading="lazy" style="width:100%;height:100%;object-fit:cover">
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center"> <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
<svg width="40" height="40" viewBox="0 0 24 24" fill="red" stroke="none"> <svg width="40" height="40" viewBox="0 0 24 24" fill="red" stroke="none">
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/> <path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/>
@ -242,56 +396,54 @@ async function loadContent() {
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg> </svg>
<span style="font-size:10px;color:var(--text-muted)">Remote</span> <span style="font-size:10px;color:var(--text-muted)">${t('content.type_remote_short')}</span>
</div>` </div>`
: c.thumbnail_path : c.thumbnail_path
? `<img src="/api/content/${c.id}/thumbnail" alt="${c.filename}" loading="lazy">` ? `<img data-auth-src="/api/content/${c.id}/thumbnail" alt="${esc(c.filename)}" style="background:var(--bg-secondary)">`
: c.mime_type?.startsWith('video/') : c.mime_type?.startsWith('video/')
? `<div class="video-icon"> ? `<div class="video-icon">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polygon points="5 3 19 12 5 21 5 3"/> <polygon points="5 3 19 12 5 21 5 3"/>
</svg> </svg>
</div>` </div>`
: `<img src="/api/content/${c.id}/file" alt="${c.filename}" loading="lazy">` : `<img data-auth-src="/api/content/${c.id}/file" alt="${esc(c.filename)}" style="background:var(--bg-secondary)">`
} }
</div> </div>
<div class="content-item-body"> <div class="content-item-body">
<div class="content-item-name" title="${c.filename}">${c.filename}</div> <div class="content-item-name" title="${esc(c.filename)}">${esc(c.filename)}</div>
<div class="content-item-size"> <div class="content-item-size">
${c.mime_type === 'video/youtube' ? 'YouTube' : c.remote_url ? 'Remote URL' : (c.mime_type?.startsWith('video/') ? 'Video' : 'Image')} ${c.mime_type === 'video/youtube' ? t('content.type_youtube') : c.remote_url ? t('content.type_remote') : (c.mime_type?.startsWith('video/') ? t('content.type_video') : t('content.type_image'))}
${c.duration_sec ? ` &middot; ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''} ${c.duration_sec ? ` &middot; ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''}
${c.file_size ? ' &middot; ' + formatFileSize(c.file_size) : ''} ${c.file_size ? ' &middot; ' + formatFileSize(c.file_size) : ''}
${c.width && c.height ? ` &middot; ${c.width}x${c.height}` : ''} ${c.width && c.height ? ` &middot; ${c.width}x${c.height}` : ''}
</div> </div>
</div> </div>
<div class="content-item-actions"> <div class="content-item-actions">
<button class="btn btn-secondary btn-sm" data-edit-content="${c.id}" title="Edit"> <button class="btn btn-secondary btn-sm" data-edit-content="${c.id}" title="${t('content.btn_edit')}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/> <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg> </svg>
Edit ${t('content.btn_edit')}
</button> </button>
<button class="btn btn-danger btn-sm" data-delete-content="${c.id}" title="Delete"> <button class="btn btn-danger btn-sm" data-delete-content="${c.id}" title="${t('content.btn_delete')}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/> <polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg> </svg>
Delete ${t('content.btn_delete')}
</button> </button>
</div> </div>
</div> </div>
`).join(''); `).join('');
hydrateAuthImages(grid);
// Populate folder dropdown // Drag-to-move: each content item exposes its id; folder cards are the drop targets.
const folderSelect = document.getElementById('folderFilter'); grid.querySelectorAll('.content-item').forEach(item => {
const folders = [...new Set(content.filter(c => c.folder).map(c => c.folder))].sort(); item.addEventListener('dragstart', (e) => {
folders.forEach(f => { e.dataTransfer.setData('text/content-id', item.dataset.contentId);
if (!folderSelect.querySelector(`option[value="${f}"]`)) { e.dataTransfer.effectAllowed = 'move';
const opt = document.createElement('option'); });
opt.value = f; opt.textContent = `${f} (${content.filter(c => c.folder === f).length})`;
folderSelect.appendChild(opt);
}
}); });
// Delete handler via event delegation // Delete handler via event delegation
@ -326,14 +478,14 @@ async function loadContent() {
if (btn.dataset.confirming === 'true') { if (btn.dataset.confirming === 'true') {
try { try {
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Deleting...'; btn.textContent = t('content.btn_deleting');
await api.deleteContent(id); await api.deleteContent(id);
showToast('Content deleted', 'success'); showToast(t('content.toast.deleted'), 'success');
loadContent(); loadContent();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Delete'; btn.textContent = t('content.btn_delete');
btn.dataset.confirming = 'false'; btn.dataset.confirming = 'false';
} }
return; return;
@ -341,14 +493,14 @@ async function loadContent() {
// First click - show confirm state // First click - show confirm state
btn.dataset.confirming = 'true'; btn.dataset.confirming = 'true';
btn.innerHTML = 'Confirm Delete?'; btn.innerHTML = t('content.btn_confirm_delete');
btn.style.background = 'var(--danger)'; btn.style.background = 'var(--danger)';
btn.style.color = 'white'; btn.style.color = 'white';
// Reset after 3 seconds if not clicked // Reset after 3 seconds if not clicked
setTimeout(() => { setTimeout(() => {
if (btn.dataset.confirming === 'true') { if (btn.dataset.confirming === 'true') {
btn.dataset.confirming = 'false'; btn.dataset.confirming = 'false';
btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> Delete`; btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> ${t('content.btn_delete')}`;
btn.style.background = ''; btn.style.background = '';
btn.style.color = ''; btn.style.color = '';
} }
@ -356,7 +508,7 @@ async function loadContent() {
}; };
} catch (err) { } catch (err) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Failed to load content</h3><p>${esc(err.message)}</p></div>`; grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('content.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
} }
} }
@ -368,46 +520,53 @@ function showEditModal(contentItem, onSave) {
const isRemote = !!contentItem.remote_url; const isRemote = !!contentItem.remote_url;
overlay.innerHTML = ` overlay.innerHTML = `
<div class="modal" style="width:500px"> <div class="modal" style="max-width:500px;width:95vw">
<div class="modal-header"> <div class="modal-header">
<h3>Edit Content</h3> <h3>${t('content.edit_modal_title')}</h3>
<button class="btn-icon" id="closeEditModal"> <button class="btn-icon" id="closeEditModal">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
<label>Filename / Display Name</label> <label>${t('content.label_filename')}</label>
<input type="text" id="editFilename" class="input" value="${contentItem.filename}"> <input type="text" id="editFilename" class="input" value="${esc(contentItem.filename)}">
</div> </div>
${isRemote ? ` ${isRemote ? `
<div class="form-group"> <div class="form-group">
<label>Remote URL</label> <label>${t('content.label_remote_url_field')}</label>
<input type="text" id="editRemoteUrl" class="input" value="${contentItem.remote_url}"> <input type="text" id="editRemoteUrl" class="input" value="${esc(contentItem.remote_url)}">
</div> </div>
` : ''} ` : ''}
<div class="form-group"> <div class="form-group">
<label>MIME Type</label> <label>${t('content.label_mime_type')}</label>
<select id="editMimeType" class="input" style="background:var(--bg-input)"> <select id="editMimeType" class="input" style="background:var(--bg-input)">
<option value="video/mp4" ${contentItem.mime_type === 'video/mp4' ? 'selected' : ''}>Video (MP4)</option> <option value="video/mp4" ${contentItem.mime_type === 'video/mp4' ? 'selected' : ''}>${t('content.mime.video_mp4')}</option>
<option value="video/webm" ${contentItem.mime_type === 'video/webm' ? 'selected' : ''}>Video (WebM)</option> <option value="video/webm" ${contentItem.mime_type === 'video/webm' ? 'selected' : ''}>${t('content.mime.video_webm')}</option>
<option value="image/jpeg" ${contentItem.mime_type === 'image/jpeg' ? 'selected' : ''}>Image (JPEG)</option> <option value="image/jpeg" ${contentItem.mime_type === 'image/jpeg' ? 'selected' : ''}>${t('content.mime.image_jpeg')}</option>
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>Image (PNG)</option> <option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>${t('content.mime.image_png')}</option>
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>Image (GIF)</option> <option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>${t('content.mime.image_gif')}</option>
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>Image (WebP)</option> <option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>${t('content.mime.image_webp')}</option>
</select>
</div>
<div class="form-group">
<label>${t('content.label_folder')}</label>
<select id="editFolderId" class="input" style="background:var(--bg-input)">
<option value="">${t('content.folder_root_option')}</option>
${state.folders.map(f => `<option value="${f.id}" ${contentItem.folder_id === f.id ? 'selected' : ''}>${esc(folderPath(f, state.folders))}</option>`).join('')}
</select> </select>
</div> </div>
${!isRemote ? ` ${!isRemote ? `
<div class="form-group"> <div class="form-group">
<label>Replace File</label> <label>${t('content.label_replace_file')}</label>
<input type="file" id="editFileReplace" accept="video/*,image/*" style="font-size:13px;color:var(--text-secondary)"> <input type="file" id="editFileReplace" accept="video/*,image/*" style="font-size:13px;color:var(--text-secondary)">
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">Leave empty to keep current file</p> <p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('content.replace_file_hint')}</p>
</div> </div>
` : ''} ` : ''}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" id="cancelEditBtn">Cancel</button> <button class="btn btn-secondary" id="cancelEditBtn">${t('common.cancel')}</button>
<button class="btn btn-primary" id="saveEditBtn">Save Changes</button> <button class="btn btn-primary" id="saveEditBtn">${t('content.save_changes')}</button>
</div> </div>
</div> </div>
`; `;
@ -428,10 +587,12 @@ function showEditModal(contentItem, onSave) {
const headers = { Authorization: 'Bearer ' + token }; const headers = { Authorization: 'Bearer ' + token };
// Update metadata // Update metadata
const folderId = overlay.querySelector('#editFolderId')?.value || '';
const updateData = {}; const updateData = {};
if (filename !== contentItem.filename) updateData.filename = filename; if (filename !== contentItem.filename) updateData.filename = filename;
if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType; if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType;
if (remoteUrl !== undefined && remoteUrl !== contentItem.remote_url) updateData.remote_url = remoteUrl; if (remoteUrl !== undefined && remoteUrl !== contentItem.remote_url) updateData.remote_url = remoteUrl;
if ((contentItem.folder_id || '') !== folderId) updateData.folder_id = folderId || null;
if (Object.keys(updateData).length > 0) { if (Object.keys(updateData).length > 0) {
await fetch('/api/content/' + contentItem.id, { await fetch('/api/content/' + contentItem.id, {
@ -453,10 +614,10 @@ function showEditModal(contentItem, onSave) {
} }
overlay.remove(); overlay.remove();
showToast('Content updated', 'success'); showToast(t('content.toast.updated'), 'success');
if (onSave) onSave(); if (onSave) onSave();
} catch (err) { } catch (err) {
showToast(err.message || 'Update failed', 'error'); showToast(err.message || t('content.error_update_failed'), 'error');
} }
}; };
} }
@ -476,13 +637,13 @@ function showPreview(content) {
${isYoutube ${isYoutube
? `<iframe src="${(() => { try { const u = new URL(src); if (!u.searchParams.has('mute')) u.searchParams.set('mute','1'); if (!u.searchParams.has('enablejsapi')) u.searchParams.set('enablejsapi','1'); if (!u.searchParams.has('origin')) u.searchParams.set('origin', window.location.origin); return u.toString(); } catch { return src; } })()}" style="width:80vw;height:45vw;max-height:80vh;display:block;border:none" allow="autoplay;encrypted-media" allowfullscreen></iframe>` ? `<iframe src="${(() => { try { const u = new URL(src); if (!u.searchParams.has('mute')) u.searchParams.set('mute','1'); if (!u.searchParams.has('enablejsapi')) u.searchParams.set('enablejsapi','1'); if (!u.searchParams.has('origin')) u.searchParams.set('origin', window.location.origin); return u.toString(); } catch { return src; } })()}" style="width:80vw;height:45vw;max-height:80vh;display:block;border:none" allow="autoplay;encrypted-media" allowfullscreen></iframe>`
: isVideo : isVideo
? `<video src="${src}" controls autoplay style="max-width:80vw;max-height:80vh;display:block"></video>` ? `<video src="${esc(src)}" controls autoplay style="max-width:80vw;max-height:80vh;display:block"></video>`
: `<img src="${src}" style="max-width:80vw;max-height:80vh;display:block">` : `<img src="${esc(src)}" style="max-width:80vw;max-height:80vh;display:block">`
} }
</div> </div>
<div style="padding:12px 16px;border-top:1px solid var(--border)"> <div style="padding:12px 16px;border-top:1px solid var(--border)">
<div style="font-weight:500">${content.filename}</div> <div style="font-weight:500">${esc(content.filename)}</div>
<div style="font-size:12px;color:var(--text-muted)">${content.mime_type} ${content.remote_url ? '(Remote URL)' : ''}</div> <div style="font-size:12px;color:var(--text-muted)">${esc(content.mime_type)} ${content.remote_url ? `(${t('content.type_remote')})` : ''}</div>
</div> </div>
</div> </div>
`; `;
@ -491,4 +652,17 @@ function showPreview(content) {
document.body.appendChild(overlay); document.body.appendChild(overlay);
} }
// Build a "Parent / Child / Leaf" path for a folder so the move-to dropdown is unambiguous
// when two folders share a name in different branches.
function folderPath(folder, all) {
const byId = new Map(all.map(f => [f.id, f]));
const parts = [folder.name];
let cursor = folder;
while (cursor.parent_id && byId.has(cursor.parent_id)) {
cursor = byId.get(cursor.parent_id);
parts.unshift(cursor.name);
}
return parts.join(' / ');
}
export function cleanup() {} export function cleanup() {}

View file

@ -2,28 +2,46 @@ import { api } from '../api.js';
import { on, off, requestScreenshot } from '../socket.js'; import { on, off, requestScreenshot } from '../socket.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
import { t, tn } from '../i18n.js';
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown']; const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
// Command types only — labels resolved through t('dashboard.cmd.<type>')
const GROUP_COMMANDS = [ const GROUP_COMMANDS = [
{ type: 'screen_on', label: 'Screen On' }, { type: 'screen_on' },
{ type: 'screen_off', label: 'Screen Off' }, { type: 'screen_off' },
{ type: 'launch', label: 'Restart App' }, { type: 'launch' },
{ type: 'update', label: 'Check Update' }, { type: 'update' },
{ type: 'reboot', label: 'Reboot', destructive: true }, { type: 'reboot', destructive: true },
{ type: 'shutdown', label: 'Shutdown', destructive: true }, { type: 'shutdown', destructive: true },
]; ];
const CMD_LABEL_KEY = {
screen_on: 'dashboard.cmd.screen_on',
screen_off: 'dashboard.cmd.screen_off',
launch: 'dashboard.cmd.restart_app',
update: 'dashboard.cmd.check_update',
reboot: 'dashboard.cmd.reboot',
shutdown: 'dashboard.cmd.shutdown',
};
let statusHandler = null; let statusHandler = null;
let screenshotHandler = null; let screenshotHandler = null;
let refreshInterval = null; let refreshInterval = null;
let playbackHandler = null;
let progressTickInterval = null;
let wallChangedHandler = null;
// device_id -> { content_name, duration_sec, started_at }
const playbackByDevice = new Map();
// Multi-select state for the "Create Video Wall" gesture. Holds device_ids
// the user has ticked via checkboxes on the dashboard cards.
const selectedDeviceIds = new Set();
function formatTimeAgo(timestamp) { function formatTimeAgo(timestamp) {
if (!timestamp) return 'Never'; if (!timestamp) return t('common.never');
const seconds = Math.floor(Date.now() / 1000 - timestamp); const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return 'Just now'; if (seconds < 60) return t('common.just_now');
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 3600) return t('common.minutes_ago', { n: Math.floor(seconds / 60) });
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; if (seconds < 86400) return t('common.hours_ago', { n: Math.floor(seconds / 3600) });
return `${Math.floor(seconds / 86400)}d ago`; return t('common.days_ago', { n: Math.floor(seconds / 86400) });
} }
function formatBytes(mb) { function formatBytes(mb) {
@ -32,14 +50,44 @@ function formatBytes(mb) {
return `${mb} MB`; return `${mb} MB`;
} }
function renderProgressFor(deviceId) {
const state = playbackByDevice.get(deviceId);
document.querySelectorAll(`#progress-${CSS.escape(deviceId)}`).forEach(el => {
if (!state) { el.style.display = 'none'; return; }
const elapsed = Math.max(0, (Date.now() - state.started_at) / 1000);
const name = state.content_name || '';
const fill = el.querySelector('.device-card-progress-fill');
const nameEl = el.querySelector('.dcp-name');
const timeEl = el.querySelector('.dcp-time');
if (state.duration_sec && state.duration_sec > 0) {
const remaining = Math.max(0, Math.ceil(state.duration_sec - elapsed));
const pct = Math.min(100, (elapsed / state.duration_sec) * 100);
fill.style.width = pct + '%';
if (nameEl) nameEl.textContent = name;
if (timeEl) timeEl.textContent = remaining + 's';
} else {
// Unknown duration (e.g. video plays to end) — show indeterminate state
fill.style.width = '100%';
fill.classList.add('indeterminate');
if (nameEl) nameEl.textContent = name;
if (timeEl) timeEl.textContent = '';
}
el.style.display = 'block';
});
}
function renderDeviceCard(device) { function renderDeviceCard(device) {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const screenshotUrl = device.screenshot_path const screenshotUrl = device.screenshot_path
? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}` ? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}`
: null; : null;
const checked = selectedDeviceIds.has(device.id);
return ` return `
<div class="device-card" data-device-id="${device.id}" onclick="window.location.hash='/device/${device.id}'"> <div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" onclick="window.location.hash='/device/${device.id}'">
<label class="device-card-select" title="Select for wall" onclick="event.stopPropagation()">
<input type="checkbox" class="device-select-cb" data-device-id="${device.id}"${checked ? ' checked' : ''}>
</label>
<div class="device-card-preview" id="preview-${device.id}"> <div class="device-card-preview" id="preview-${device.id}">
${screenshotUrl ${screenshotUrl
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">` ? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
@ -49,17 +97,21 @@ function renderDeviceCard(device) {
<line x1="8" y1="21" x2="16" y2="21"/> <line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/> <line x1="12" y1="17" x2="12" y2="21"/>
</svg> </svg>
<span>No preview available</span> <span>${t('dashboard.no_preview')}</span>
</div>` </div>`
} }
<div class="device-card-status"> <div class="device-card-status">
<span class="status-dot ${device.status}"></span> <span class="status-dot ${device.status}"></span>
<span>${device.status === 'provisioning' ? 'Awaiting Pairing' : device.status}</span> <span>${device.status === 'provisioning' ? t('dashboard.awaiting_pairing') : device.status}</span>
</div> </div>
${device.status === 'provisioning' && device.pairing_code ? ` ${device.status === 'provisioning' && device.pairing_code ? `
<div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#f59e0b;padding:4px 12px;border-radius:6px;font-size:13px;font-weight:600;letter-spacing:2px;font-family:monospace"> <div style="position:absolute;bottom:8px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#f59e0b;padding:4px 12px;border-radius:6px;font-size:13px;font-weight:600;letter-spacing:2px;font-family:monospace">
${device.pairing_code} ${device.pairing_code}
</div>` : ''} </div>` : ''}
<div class="device-card-progress" id="progress-${device.id}" style="display:none">
<div class="device-card-progress-label"><span class="dcp-name"></span><span class="dcp-time"></span></div>
<div class="device-card-progress-track"><div class="device-card-progress-fill"></div></div>
</div>
</div> </div>
<div class="device-card-body"> <div class="device-card-body">
<div class="device-card-name">${esc(device.name)}</div> <div class="device-card-name">${esc(device.name)}</div>
@ -105,28 +157,77 @@ function renderDeviceCard(device) {
`; `;
} }
function renderGroupSection(group, devices) { function renderWallCard(wall) {
// Compose a tiny grid preview using the wall's actual cols×rows. Each cell
// is filled (assigned) or hollow (empty slot).
const cells = [];
for (let r = 0; r < wall.grid_rows; r++) {
for (let c = 0; c < wall.grid_cols; c++) {
const dev = (wall.devices || []).find(d => d.grid_col === c && d.grid_row === r);
cells.push(`<div class="wall-card-cell${dev ? ' filled' : ''}" title="${dev ? esc(dev.device_name) : '[' + c + ',' + r + ']'}"></div>`);
}
}
const onlineCount = (wall.devices || []).filter(d => d.device_status === 'online').length;
return `
<div class="device-card wall-card" data-wall-id="${wall.id}" onclick="window.location.hash='#/wall/${wall.id}'">
<div class="device-card-preview wall-card-preview">
<div class="wall-card-grid" style="grid-template-columns:repeat(${wall.grid_cols},1fr);grid-template-rows:repeat(${wall.grid_rows},1fr)">${cells.join('')}</div>
<div class="device-card-status">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
<span>${wall.grid_cols}×${wall.grid_rows} wall</span>
</div>
</div>
<div class="device-card-body">
<div class="device-card-name">${esc(wall.name)}</div>
<div class="device-card-meta">
<div class="meta-item">${(wall.devices || []).length} ${(wall.devices || []).length === 1 ? 'tile' : 'tiles'}</div>
<div class="meta-item" style="color:${onlineCount === (wall.devices || []).length ? 'var(--success)' : 'var(--text-muted)'}">${onlineCount} online</div>
</div>
</div>
</div>
`;
}
function getGroupPlaylistLabel(devices, playlists) {
const playlistMap = new Map((playlists || []).map(p => [p.id, p]));
const assigned = devices.filter(d => d.playlist_id).map(d => d.playlist_id);
if (assigned.length === 0) return '';
const unique = [...new Set(assigned)];
if (unique.length === 1) {
const pl = playlistMap.get(unique[0]);
return pl ? esc(pl.name) : t('dashboard.unknown_playlist');
}
return t('dashboard.mixed_playlists');
}
function renderGroupSection(group, devices, playlists) {
const onlineCount = devices.filter(d => d.status === 'online').length; const onlineCount = devices.filter(d => d.status === 'online').length;
const playlistLabel = getGroupPlaylistLabel(devices, playlists);
return ` return `
<div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px"> <div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#3B82F6')}"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#3B82F6')}">
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px">
<strong style="font-size:15px">${esc(group.name)}</strong> <strong style="font-size:15px">${esc(group.name)}</strong>
<span style="color:var(--text-muted);font-size:12px">${devices.length} device${devices.length !== 1 ? 's' : ''} &middot; ${onlineCount} online</span> <span style="color:var(--text-muted);font-size:12px">${tn('dashboard.devices_count', devices.length)} &middot; ${t('dashboard.online_count', { n: onlineCount })}</span>
${playlistLabel ? `<span style="font-size:11px;color:var(--text-secondary);background:var(--bg-primary);padding:2px 8px;border-radius:10px">${t('dashboard.playlist_label', { name: playlistLabel })}</span>` : ''}
</div> </div>
<div style="display:flex;gap:6px;align-items:center"> <div style="display:flex;gap:6px;align-items:center">
${devices.length > 0 ? ` ${devices.length > 0 ? `
<select class="input group-playlist-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" style="width:160px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
<option value="">${t('dashboard.set_playlist_placeholder')}</option>
${(playlists || []).map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' ' + t('dashboard.draft_suffix') : ''}</option>`).join('')}
</select>
<select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)"> <select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
<option value="">Send Command...</option> <option value="">${t('dashboard.send_command_placeholder')}</option>
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${c.label}</option>`).join('')} ${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${t(CMD_LABEL_KEY[c.type])}</option>`).join('')}
</select> </select>
` : ''} ` : ''}
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="Add/remove devices">Manage</button> <button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="${t('dashboard.manage_tooltip')}">${t('dashboard.manage')}</button>
<button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="Delete group">&#x2715;</button> <button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="${t('dashboard.delete_group_tooltip')}">&#x2715;</button>
</div> </div>
</div> </div>
<div class="device-grid"> <div class="device-grid">
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : '<div style="color:var(--text-muted);font-size:13px;padding:8px 12px">No devices in this group. Click Manage to add some.</div>'} ${devices.length > 0 ? devices.map(renderDeviceCard).join('') : `<div style="color:var(--text-muted);font-size:13px;padding:8px 12px">${t('dashboard.no_devices_in_group')}</div>`}
</div> </div>
</div> </div>
`; `;
@ -136,26 +237,36 @@ export function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>Displays <span class="help-tip" data-tip="Your paired display devices. Green = online, red = offline. Click a device to manage its playlist, view telemetry, or use remote control.">?</span></h1> <h1>${t('dashboard.title')} <span class="help-tip" data-tip="${t('dashboard.help_tip')}">?</span></h1>
<div class="subtitle">Manage your remote displays</div> <div class="subtitle">${t('dashboard.subtitle')}</div>
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn" id="createGroupBtn">+ Group</button> <button class="btn" id="createGroupBtn">${t('dashboard.create_group')}</button>
<button class="btn btn-primary" id="addDeviceBtn"> <button class="btn btn-primary" id="addDeviceBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/> <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg> </svg>
Add Display ${t('dashboard.add')}
</button> </button>
</div> </div>
</div> </div>
<div id="dashStats" style="display:flex;gap:12px;margin-bottom:16px"></div> <div id="selectionBar" style="display:none;align-items:center;gap:10px;padding:8px 12px;margin-bottom:12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px">
<span id="selectionCount" style="font-weight:500;font-size:13px"></span>
<button class="btn btn-primary btn-sm" id="createWallBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:4px">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/>
</svg>
Create Video Wall
</button>
<button class="btn btn-sm" id="clearSelectionBtn">Clear</button>
</div>
<div id="dashStats" class="dash-stats-row" style="display:flex;gap:12px;margin-bottom:16px"></div>
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center"> <div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
<input type="text" id="deviceSearch" class="input" placeholder="Search displays..." style="max-width:300px"> <input type="text" id="deviceSearch" class="input" placeholder="${t('dashboard.search')}" style="max-width:300px">
<select id="deviceFilter" class="input" style="width:140px;background:var(--bg-input)"> <select id="deviceFilter" class="input" style="width:140px;background:var(--bg-input)">
<option value="">All Status</option> <option value="">${t('dashboard.all_status')}</option>
<option value="online">Online</option> <option value="online">${t('dashboard.online')}</option>
<option value="offline">Offline</option> <option value="offline">${t('dashboard.offline')}</option>
</select> </select>
</div> </div>
<div id="groupedDevices"></div> <div id="groupedDevices"></div>
@ -191,13 +302,13 @@ export function render(container) {
const code = document.getElementById('pairingCodeInput').value.trim(); const code = document.getElementById('pairingCodeInput').value.trim();
const name = document.getElementById('deviceNameInput').value.trim(); const name = document.getElementById('deviceNameInput').value.trim();
if (!code || code.length !== 6) { if (!code || code.length !== 6) {
showToast('Enter a valid 6-digit pairing code', 'error'); showToast(t('dashboard.error_pairing_code'), 'error');
return; return;
} }
try { try {
await api.pairDevice(code, name || undefined); await api.pairDevice(code, name || undefined);
document.getElementById('addDeviceModal').style.display = 'none'; document.getElementById('addDeviceModal').style.display = 'none';
showToast('Display paired successfully!', 'success'); showToast(t('dashboard.toast.display_paired'), 'success');
loadDashboard(); loadDashboard();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -206,15 +317,37 @@ export function render(container) {
// Create group // Create group
container.querySelector('#createGroupBtn').addEventListener('click', async () => { container.querySelector('#createGroupBtn').addEventListener('click', async () => {
const name = prompt('Group name:'); const name = prompt(t('dashboard.prompt_group_name'));
if (!name) return; if (!name) return;
try { try {
await api.createGroup(name); await api.createGroup(name);
showToast('Group created', 'success'); showToast(t('dashboard.toast.group_created'), 'success');
loadDashboard(); loadDashboard();
} catch (e) { showToast(e.message, 'error'); } } catch (e) { showToast(e.message, 'error'); }
}); });
// Multi-select: a checkbox on each device card adds to selectedDeviceIds.
// The selection bar shows when 1+ are selected; "Create Video Wall" is the
// primary action — it creates the wall, removes devices from any group,
// assigns them, and navigates to the editor.
container.addEventListener('change', (ev) => {
const cb = ev.target.closest?.('.device-select-cb');
if (!cb) return;
const id = cb.dataset.deviceId;
if (cb.checked) selectedDeviceIds.add(id); else selectedDeviceIds.delete(id);
cb.closest('.device-card')?.classList.toggle('selected', cb.checked);
refreshSelectionBar();
});
document.getElementById('clearSelectionBtn').addEventListener('click', () => {
selectedDeviceIds.clear();
document.querySelectorAll('.device-select-cb').forEach(cb => { cb.checked = false; });
document.querySelectorAll('.device-card.selected').forEach(c => c.classList.remove('selected'));
refreshSelectionBar();
});
document.getElementById('createWallBtn').addEventListener('click', () => createWallFromSelection());
// Load everything // Load everything
loadDashboard(); loadDashboard();
@ -228,7 +361,6 @@ export function render(container) {
}; };
screenshotHandler = (data) => { screenshotHandler = (data) => {
// Update all instances of this device's preview (may appear in multiple groups)
document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => { document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => {
const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token')); const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token'));
const img = preview.querySelector('img'); const img = preview.querySelector('img');
@ -244,10 +376,28 @@ export function render(container) {
const deviceAddedHandler = () => loadDashboard(); const deviceAddedHandler = () => loadDashboard();
const deviceRemovedHandler = () => loadDashboard(); const deviceRemovedHandler = () => loadDashboard();
playbackHandler = (data) => {
if (!data?.device_id) return;
playbackByDevice.set(data.device_id, {
content_name: data.content_name || '',
duration_sec: data.duration_sec || null,
started_at: data.started_at || Date.now(),
});
renderProgressFor(data.device_id);
};
wallChangedHandler = () => loadDashboard();
on('device-status', statusHandler); on('device-status', statusHandler);
on('screenshot-ready', screenshotHandler); on('screenshot-ready', screenshotHandler);
on('device-added', deviceAddedHandler); on('device-added', deviceAddedHandler);
on('device-removed', deviceRemovedHandler); on('device-removed', deviceRemovedHandler);
on('playback-progress', playbackHandler);
on('wall-changed', wallChangedHandler);
progressTickInterval = setInterval(() => {
for (const id of playbackByDevice.keys()) renderProgressFor(id);
}, 1000);
// Request fresh screenshots on load // Request fresh screenshots on load
setTimeout(() => { setTimeout(() => {
@ -263,12 +413,77 @@ export function render(container) {
}, 30000); }, 30000);
} }
function refreshSelectionBar() {
const bar = document.getElementById('selectionBar');
const count = document.getElementById('selectionCount');
if (!bar || !count) return;
const n = selectedDeviceIds.size;
if (n === 0) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
// Need at least 2 to make a wall - surface the constraint inline so the
// greyed-out button isn't just silently unresponsive.
count.textContent = n < 2
? `${n} display selected - pick 1 more to create a wall`
: `${n} displays selected`;
const btn = document.getElementById('createWallBtn');
btn.disabled = n < 2;
btn.title = n < 2 ? 'Select at least 2 displays to create a video wall' : '';
}
// Pick a sensible default grid for n devices: prefer near-square layouts,
// breaking ties toward more columns (more common physical wall layout).
function defaultGridForCount(n) {
if (n <= 1) return { cols: 1, rows: 1 };
if (n === 2) return { cols: 2, rows: 1 };
if (n === 3) return { cols: 3, rows: 1 };
if (n === 4) return { cols: 2, rows: 2 };
if (n === 6) return { cols: 3, rows: 2 };
if (n === 8) return { cols: 4, rows: 2 };
if (n === 9) return { cols: 3, rows: 3 };
// Generic fallback — square-ish, columns >= rows
const cols = Math.ceil(Math.sqrt(n));
const rows = Math.ceil(n / cols);
return { cols, rows };
}
async function createWallFromSelection() {
const ids = [...selectedDeviceIds];
if (ids.length < 2) { showToast('Select at least 2 displays', 'error'); return; }
const name = prompt('Name this video wall:', `Wall ${new Date().toLocaleString()}`);
if (!name) return;
const { cols, rows } = defaultGridForCount(ids.length);
try {
const wall = await api.createWall({ name, grid_cols: cols, grid_rows: rows });
// Pack selected devices into row-major order. The user can reposition in
// the editor; this just gives every selection a sensible starting tile.
const placement = ids.slice(0, cols * rows).map((id, i) => ({
device_id: id,
grid_col: i % cols,
grid_row: Math.floor(i / cols),
}));
await api.setWallDevices(wall.id, placement);
selectedDeviceIds.clear();
showToast('Video wall created', 'success');
window.location.hash = `#/wall/${wall.id}`;
} catch (e) {
showToast(e.message, 'error');
}
}
async function loadDashboard() { async function loadDashboard() {
const main = document.getElementById('groupedDevices'); const main = document.getElementById('groupedDevices');
if (!main) return; if (!main) return;
try { try {
const [devices, groups] = await Promise.all([api.getDevices(), api.getGroups()]); const [rawDevices, groups, playlists, walls] = await Promise.all([
api.getDevices(), api.getGroups(), api.getPlaylists(), api.getWalls(),
]);
// Deduplicate devices by id — a stale reconnect race can briefly cause the same
// device to appear twice in the list. Last-write-wins keeps the freshest state.
const seen = new Map();
for (const d of rawDevices) seen.set(d.id, d);
const devices = Array.from(seen.values());
// Stats // Stats
const online = devices.filter(d => d.status === 'online').length; const online = devices.filter(d => d.status === 'online').length;
@ -278,20 +493,20 @@ async function loadDashboard() {
if (statsEl) { if (statsEl) {
statsEl.innerHTML = ` statsEl.innerHTML = `
<div class="info-card" style="flex:1;min-width:120px"> <div class="info-card" style="flex:1;min-width:120px">
<div class="info-card-label">Total Displays</div> <div class="info-card-label">${t('dashboard.total_displays')}</div>
<div class="info-card-value">${devices.length}</div> <div class="info-card-value">${devices.length}</div>
</div> </div>
<div class="info-card" style="flex:1;min-width:120px"> <div class="info-card" style="flex:1;min-width:120px">
<div class="info-card-label">Online</div> <div class="info-card-label">${t('dashboard.online')}</div>
<div class="info-card-value" style="color:var(--success)">${online}</div> <div class="info-card-value" style="color:var(--success)">${online}</div>
</div> </div>
<div class="info-card" style="flex:1;min-width:120px"> <div class="info-card" style="flex:1;min-width:120px">
<div class="info-card-label">Offline</div> <div class="info-card-label">${t('dashboard.offline')}</div>
<div class="info-card-value" style="color:${offline > 0 ? 'var(--danger)' : 'var(--text-muted)'}">${offline}</div> <div class="info-card-value" style="color:${offline > 0 ? 'var(--danger)' : 'var(--text-muted)'}">${offline}</div>
</div> </div>
${provisioning > 0 ? ` ${provisioning > 0 ? `
<div class="info-card" style="flex:1;min-width:120px"> <div class="info-card" style="flex:1;min-width:120px">
<div class="info-card-label">Awaiting Pairing</div> <div class="info-card-label">${t('dashboard.awaiting_pairing')}</div>
<div class="info-card-value" style="color:var(--warning,#f59e0b)">${provisioning}</div> <div class="info-card-value" style="color:var(--warning,#f59e0b)">${provisioning}</div>
</div>` : ''} </div>` : ''}
`; `;
@ -305,42 +520,72 @@ async function loadDashboard() {
<line x1="8" y1="21" x2="16" y2="21"/> <line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/> <line x1="12" y1="17" x2="12" y2="21"/>
</svg> </svg>
<h3>No displays yet</h3> <h3>${t('dashboard.no_displays')}</h3>
<p>Install the ScreenTinker app on your Apolosign TV and pair it using the button above.</p> <p>${t('dashboard.no_displays_desc')}</p>
</div> </div>
`; `;
return; return;
} }
// Devices that belong to a wall are owned by that wall — they don't appear
// as their own cards anywhere on the dashboard. The wall's card stands in.
const walledDeviceIds = new Set();
for (const w of (walls || [])) for (const d of (w.devices || [])) walledDeviceIds.add(d.device_id);
const dashboardDevices = devices.filter(d => !walledDeviceIds.has(d.id));
// Fetch group memberships // Fetch group memberships
const groupsWithDevices = await Promise.all(groups.map(async g => { const groupsWithDevices = await Promise.all(groups.map(async g => {
const members = await api.getGroupDevices(g.id); const members = await api.getGroupDevices(g.id);
const memberIds = new Set(members.map(m => m.id)); const memberIds = new Set(members.map(m => m.id));
// Use full device data from the main devices list (has telemetry/screenshots) // Use full device data from the main devices list (has telemetry/screenshots)
const fullDevices = devices.filter(d => memberIds.has(d.id)); // and exclude any wall members.
const fullDevices = dashboardDevices.filter(d => memberIds.has(d.id));
return { ...g, devices: fullDevices, memberIds }; return { ...g, devices: fullDevices, memberIds };
})); }));
// Find ungrouped devices // Render each device exactly once: the first group it belongs to wins.
const allGroupedIds = new Set(); // memberIds is preserved for the Manage modal so multi-group membership info stays accurate.
groupsWithDevices.forEach(g => g.memberIds.forEach(id => allGroupedIds.add(id))); const renderedIds = new Set();
const ungrouped = devices.filter(d => !allGroupedIds.has(d.id)); for (const g of groupsWithDevices) {
g.devices = g.devices.filter(d => {
if (renderedIds.has(d.id)) return false;
renderedIds.add(d.id);
return true;
});
}
const ungrouped = dashboardDevices.filter(d => !renderedIds.has(d.id));
let html = ''; let html = '';
// Render each group with its devices // Walls render before groups: they're a higher-level construct (multiple
for (const g of groupsWithDevices) { // physical screens acting as one logical display).
html += renderGroupSection(g, g.devices); if ((walls || []).length > 0) {
html += `
<div class="wall-section" style="margin-bottom:24px">
<div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid #8b5cf6">
<strong style="font-size:15px">Video Walls</strong>
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${walls.length} wall${walls.length === 1 ? '' : 's'}</span>
</div>
<div class="device-grid">${walls.map(renderWallCard).join('')}</div>
</div>
`;
} }
// Render ungrouped devices // Render each group with its devices
for (const g of groupsWithDevices) {
html += renderGroupSection(g, g.devices, playlists);
}
// Render ungrouped devices. The wrapper is tagged data-ungrouped="1" so
// attachGroupHandlers can wire it as a drop target — dropping a device here
// removes it from every group it currently belongs to.
if (ungrouped.length > 0) { if (ungrouped.length > 0) {
html += ` html += `
<div style="margin-bottom:24px"> <div class="ungrouped-section" data-ungrouped="1" style="margin-bottom:24px">
${groups.length > 0 ? ` ${groups.length > 0 ? `
<div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid var(--text-muted)"> <div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid var(--text-muted)">
<strong style="font-size:15px;color:var(--text-muted)">Ungrouped</strong> <strong style="font-size:15px;color:var(--text-muted)">${t('dashboard.ungrouped')}</strong>
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${ungrouped.length} device${ungrouped.length !== 1 ? 's' : ''}</span> <span style="color:var(--text-muted);font-size:12px;margin-left:10px">${tn('dashboard.devices_count', ungrouped.length)}</span>
</div>` : ''} </div>` : ''}
<div class="device-grid"> <div class="device-grid">
${ungrouped.map(renderDeviceCard).join('')} ${ungrouped.map(renderDeviceCard).join('')}
@ -350,14 +595,133 @@ async function loadDashboard() {
} }
main.innerHTML = html; main.innerHTML = html;
attachGroupHandlers(groupsWithDevices, devices); attachGroupHandlers(groupsWithDevices, dashboardDevices);
// Drop any selections for devices that have since been absorbed into a
// wall, and update the toolbar.
for (const id of [...selectedDeviceIds]) {
if (walledDeviceIds.has(id)) selectedDeviceIds.delete(id);
}
refreshSelectionBar();
} catch (err) { } catch (err) {
main.innerHTML = `<div class="empty-state"><h3>Failed to load displays</h3><p>${esc(err.message)}</p></div>`; main.innerHTML = `<div class="empty-state"><h3>${t('dashboard.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`;
} }
} }
function attachGroupHandlers(groupsWithDevices, allDevices) { function attachGroupHandlers(groupsWithDevices, allDevices) {
// Drag-and-drop: device cards are draggable; group sections + the Ungrouped
// wrapper are drop targets. Drop on a group adds membership (mirrors the
// Manage modal). Drop on Ungrouped removes the device from every group it's
// currently a member of.
const groupsByDeviceId = new Map();
for (const g of groupsWithDevices) {
g.memberIds.forEach(id => {
if (!groupsByDeviceId.has(id)) groupsByDeviceId.set(id, []);
groupsByDeviceId.get(id).push({ id: g.id, name: g.name });
});
}
document.querySelectorAll('.device-card').forEach(card => {
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/device-id', card.dataset.deviceId);
e.dataTransfer.setData('text/device-name', card.dataset.deviceName || '');
e.dataTransfer.effectAllowed = 'move';
});
});
function highlightOn(el) { el.style.outline = '2px solid var(--primary)'; el.style.outlineOffset = '2px'; }
function highlightOff(el) { el.style.outline = ''; el.style.outlineOffset = ''; }
document.querySelectorAll('.group-section').forEach(section => {
section.addEventListener('dragover', (e) => {
if (!e.dataTransfer.types.includes('text/device-id')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
highlightOn(section);
});
section.addEventListener('dragleave', (e) => {
// Avoid flicker when moving across child elements
if (e.target === section) highlightOff(section);
});
section.addEventListener('drop', async (e) => {
e.preventDefault();
highlightOff(section);
const deviceId = e.dataTransfer.getData('text/device-id');
const deviceName = e.dataTransfer.getData('text/device-name') || 'this device';
if (!deviceId) return;
const groupId = section.dataset.groupId;
const targetGroup = groupsWithDevices.find(g => g.id === groupId);
if (!targetGroup) return;
// Already in this group — no-op.
if (targetGroup.memberIds.has(deviceId)) {
showToast(t('dashboard.toast.already_in_group', { name: deviceName, group: targetGroup.name }), 'info');
return;
}
// If the device is in another group, mirror the Manage modal's confirm.
const others = (groupsByDeviceId.get(deviceId) || []).map(g => g.name);
if (others.length > 0) {
if (!confirm(t('dashboard.confirm_add_to_group', { name: deviceName, groups: others.join(', '), target: targetGroup.name }))) return;
}
try {
await api.addDeviceToGroup(groupId, deviceId);
showToast(t('dashboard.toast.moved_device', { name: deviceName, group: targetGroup.name }), 'success');
loadDashboard();
} catch (err) { showToast(err.message, 'error'); }
});
});
// Ungrouped wrapper: remove device from every group it's in.
document.querySelectorAll('[data-ungrouped="1"]').forEach(section => {
section.addEventListener('dragover', (e) => {
if (!e.dataTransfer.types.includes('text/device-id')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
highlightOn(section);
});
section.addEventListener('dragleave', (e) => {
if (e.target === section) highlightOff(section);
});
section.addEventListener('drop', async (e) => {
e.preventDefault();
highlightOff(section);
const deviceId = e.dataTransfer.getData('text/device-id');
const deviceName = e.dataTransfer.getData('text/device-name') || 'this device';
if (!deviceId) return;
const memberships = groupsByDeviceId.get(deviceId) || [];
if (memberships.length === 0) return; // already ungrouped
try {
await Promise.all(memberships.map(m => api.removeDeviceFromGroup(m.id, deviceId)));
showToast(tn('dashboard.toast.removed_device', memberships.length, { name: deviceName }), 'success');
loadDashboard();
} catch (err) { showToast(err.message, 'error'); }
});
});
// Playlist assignment handlers
document.querySelectorAll('.group-playlist-select').forEach(select => {
select.addEventListener('change', async (e) => {
const playlistId = e.target.value;
if (!playlistId) return;
const groupId = e.target.dataset.groupId;
const groupName = e.target.dataset.groupName;
const playlistName = e.target.options[e.target.selectedIndex].textContent;
if (!confirm(t('dashboard.confirm_assign_playlist', { playlist: playlistName, group: groupName }))) {
e.target.value = '';
return;
}
try {
const result = await api.groupAssignPlaylist(groupId, playlistId);
showToast(tn('dashboard.toast.playlist_assigned', result.devices_updated), 'success');
} catch (err) {
showToast(err.message, 'error');
}
e.target.value = '';
});
});
// Command select handlers // Command select handlers
document.querySelectorAll('.group-cmd-select').forEach(select => { document.querySelectorAll('.group-cmd-select').forEach(select => {
select.addEventListener('change', async (e) => { select.addEventListener('change', async (e) => {
@ -366,9 +730,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
const groupId = e.target.dataset.groupId; const groupId = e.target.dataset.groupId;
const groupName = e.target.dataset.groupName; const groupName = e.target.dataset.groupName;
const count = e.target.dataset.deviceCount; const count = e.target.dataset.deviceCount;
const cmdLabel = t(CMD_LABEL_KEY[type] || type);
if (DESTRUCTIVE_COMMANDS.includes(type)) { if (DESTRUCTIVE_COMMANDS.includes(type)) {
if (!confirm(`${type.toUpperCase()} all ${count} device${count !== '1' ? 's' : ''} in "${groupName}"?\n\nThis cannot be undone.`)) { if (!confirm(t('dashboard.confirm_destructive_command', { cmd: cmdLabel.toUpperCase(), n: count, group: groupName }))) {
e.target.value = ''; e.target.value = '';
return; return;
} }
@ -376,7 +741,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
try { try {
const result = await api.sendGroupCommand(groupId, type); const result = await api.sendGroupCommand(groupId, type);
showToast(`${type} sent to ${result.sent}/${result.total} devices${result.offline > 0 ? ` (${result.offline} offline)` : ''}`, result.offline > 0 ? 'warning' : 'success'); const msg = result.offline > 0
? t('dashboard.toast.command_sent_with_offline', { cmd: cmdLabel, sent: result.sent, total: result.total, offline: result.offline })
: t('dashboard.toast.command_sent', { cmd: cmdLabel, sent: result.sent, total: result.total });
showToast(msg, result.offline > 0 ? 'warning' : 'success');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -389,10 +757,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
const id = btn.dataset.groupDelete; const id = btn.dataset.groupDelete;
if (!confirm('Delete this group? Devices will not be affected.')) return; if (!confirm(t('dashboard.confirm_delete_group'))) return;
try { try {
await api.deleteGroup(id); await api.deleteGroup(id);
showToast('Group deleted', 'success'); showToast(t('dashboard.toast.group_deleted'), 'success');
loadDashboard(); loadDashboard();
} catch (e) { showToast(e.message, 'error'); } } catch (e) { showToast(e.message, 'error'); }
}); });
@ -414,7 +782,7 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
modal.innerHTML = ` modal.innerHTML = `
<div style="background:var(--bg-card);border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:70vh;overflow-y:auto"> <div style="background:var(--bg-card);border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:70vh;overflow-y:auto">
<h3 style="margin:0 0 4px">${esc(group.name)}</h3> <h3 style="margin:0 0 4px">${esc(group.name)}</h3>
<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">Check devices to add them to this group</p> <p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">${t('dashboard.manage_group_subtitle')}</p>
<div style="display:flex;flex-direction:column;gap:6px"> <div style="display:flex;flex-direction:column;gap:6px">
${allDevices.filter(d => d.status !== 'provisioning').map(d => { ${allDevices.filter(d => d.status !== 'provisioning').map(d => {
const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name); const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name);
@ -429,7 +797,7 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
}).join('')} }).join('')}
</div> </div>
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end"> <div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
<button class="btn" id="manageGroupClose">Done</button> <button class="btn" id="manageGroupClose">${t('common.done')}</button>
</div> </div>
</div> </div>
`; `;
@ -442,9 +810,10 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
cb.addEventListener('change', async () => { cb.addEventListener('change', async () => {
const deviceId = cb.dataset.deviceId; const deviceId = cb.dataset.deviceId;
const existingGroups = cb.dataset.inGroups; const existingGroups = cb.dataset.inGroups;
const cbName = cb.closest('label')?.querySelector('span:not(.status-dot)')?.textContent || '';
try { try {
if (cb.checked && existingGroups) { if (cb.checked && existingGroups) {
if (!confirm(`This device is already in: ${existingGroups}\n\nAdd it to "${group.name}" too?`)) { if (!confirm(t('dashboard.confirm_add_to_group', { name: cbName, groups: existingGroups, target: group.name }))) {
cb.checked = false; cb.checked = false;
return; return;
} }
@ -467,10 +836,17 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
export function cleanup() { export function cleanup() {
if (statusHandler) off('device-status', statusHandler); if (statusHandler) off('device-status', statusHandler);
if (screenshotHandler) off('screenshot-ready', screenshotHandler); if (screenshotHandler) off('screenshot-ready', screenshotHandler);
if (playbackHandler) off('playback-progress', playbackHandler);
if (wallChangedHandler) off('wall-changed', wallChangedHandler);
off('device-added', () => {}); off('device-added', () => {});
off('device-removed', () => {}); off('device-removed', () => {});
if (refreshInterval) clearInterval(refreshInterval); if (refreshInterval) clearInterval(refreshInterval);
if (progressTickInterval) clearInterval(progressTickInterval);
statusHandler = null; statusHandler = null;
screenshotHandler = null; screenshotHandler = null;
playbackHandler = null;
wallChangedHandler = null;
refreshInterval = null; refreshInterval = null;
progressTickInterval = null;
playbackByDevice.clear();
} }

View file

@ -1,16 +1,20 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t } from '../i18n.js';
// Background swatches: ids resolve to translated names; values are the actual
// CSS to apply.
const BACKGROUNDS = [ const BACKGROUNDS = [
{ name: 'Black', value: '#000000' }, { id: 'black', value: '#000000' },
{ name: 'Dark Blue', value: '#0f172a' }, { id: 'dark_blue', value: '#0f172a' },
{ name: 'Dark Gradient', value: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)' }, { id: 'dark_gradient', value: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)' },
{ name: 'Blue Gradient', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }, { id: 'blue_gradient', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ name: 'Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' }, { id: 'sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
{ name: 'Ocean', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' }, { id: 'ocean', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
{ name: 'Forest', value: 'linear-gradient(135deg, #134e5e 0%, #71b280 100%)' }, { id: 'forest', value: 'linear-gradient(135deg, #134e5e 0%, #71b280 100%)' },
{ name: 'Dark Red', value: 'linear-gradient(135deg, #200122 0%, #6f0000 100%)' }, { id: 'dark_red', value: 'linear-gradient(135deg, #200122 0%, #6f0000 100%)' },
{ name: 'White', value: '#FFFFFF' }, { id: 'white', value: '#FFFFFF' },
]; ];
const FONTS = ['Arial', 'Helvetica', 'Georgia', 'Impact', 'Verdana', 'Trebuchet MS', 'Courier New', 'Times New Roman']; const FONTS = ['Arial', 'Helvetica', 'Georgia', 'Impact', 'Verdana', 'Trebuchet MS', 'Courier New', 'Times New Roman'];
@ -30,64 +34,77 @@ export function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div><h1>Content Designer <span class="help-tip" data-tip="Create custom signage with live elements: clocks, weather, RSS tickers, countdowns, QR codes. Publish as a widget or export as PNG.">?</span></h1><div class="subtitle">Create dynamic signage content</div></div> <div><h1>${t('designer.title')} <span class="help-tip" data-tip="${t('designer.help_tip')}">?</span></h1><div class="subtitle">${t('designer.subtitle')}</div></div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-secondary" id="loadDesignBtn">Load Design</button> <button class="btn btn-secondary" id="loadDesignBtn">${t('designer.load_design')}</button>
<button class="btn btn-secondary" id="exportPngBtn">Export PNG</button> <button class="btn btn-secondary" id="exportPngBtn">${t('designer.export_png')}</button>
<button class="btn btn-primary" id="publishBtn">Publish to Library</button> <button class="btn btn-primary" id="publishBtn">${t('designer.publish')}</button>
</div> </div>
</div> </div>
<div style="display:flex;gap:20px"> <div style="display:flex;gap:20px">
<!-- Preview --> <!-- Preview -->
<div style="flex:1"> <div style="flex:1">
<div id="previewWrap" style="position:relative;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;background:#000;aspect-ratio:16/9"> <div id="previewWrap" style="position:relative;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;background:#000;aspect-ratio:16/9">
<div id="designPreview" style="position:relative;width:100%;height:100%;overflow:hidden"></div> <div id="designPreview" style="position:relative;width:100%;height:100%;overflow:hidden;container-type:inline-size"></div>
</div> </div>
<p style="font-size:11px;color:var(--text-muted);margin-top:8px">Click elements to select. Drag to reposition. Live preview updates in real-time.</p> <p style="font-size:11px;color:var(--text-muted);margin-top:8px">${t('designer.preview_hint')}</p>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
<div style="width:300px;display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 120px);overflow-y:auto"> <div style="width:300px;display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 120px);overflow-y:auto">
<!-- AI Generate (#41) -->
<div style="background:var(--bg-card);border:1px solid var(--accent);border-radius:var(--radius);padding:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h4 style="font-size:13px">${t('designer.ai.title')}</h4>
<button class="btn-icon" id="aiSettingsBtn" title="${t('designer.ai.settings')}" style="padding:2px">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
<textarea id="aiPrompt" rows="2" class="input" placeholder="${t('designer.ai.placeholder')}" style="width:100%;resize:vertical;font-size:12px"></textarea>
<button class="btn btn-primary btn-sm" id="aiGenerateBtn" style="width:100%;justify-content:center;margin-top:6px">${t('designer.ai.generate')}</button>
<div id="aiStatus" style="font-size:11px;color:var(--text-muted);margin-top:6px"></div>
</div>
<!-- Add Elements --> <!-- Add Elements -->
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px"> <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:10px">Add Element</h4> <h4 style="font-size:13px;margin-bottom:10px">${t('designer.add_element')}</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<button class="btn btn-secondary btn-sm" id="addText" style="justify-content:center">&#128172; Text</button> <button class="btn btn-secondary btn-sm" id="addText" style="justify-content:center">&#128172; ${t('designer.el.text')}</button>
<button class="btn btn-secondary btn-sm" id="addHeading" style="justify-content:center">&#128220; Heading</button> <button class="btn btn-secondary btn-sm" id="addHeading" style="justify-content:center">&#128220; ${t('designer.el.heading')}</button>
<button class="btn btn-secondary btn-sm" id="addImage" style="justify-content:center">&#128247; Image</button> <button class="btn btn-secondary btn-sm" id="addImage" style="justify-content:center">&#128247; ${t('designer.el.image')}</button>
<button class="btn btn-secondary btn-sm" id="addVideo" style="justify-content:center">&#127916; Video</button> <button class="btn btn-secondary btn-sm" id="addVideo" style="justify-content:center">&#127916; ${t('designer.el.video')}</button>
<button class="btn btn-secondary btn-sm" id="addClock" style="justify-content:center">&#128339; Clock</button> <button class="btn btn-secondary btn-sm" id="addClock" style="justify-content:center">&#128339; ${t('designer.el.clock')}</button>
<button class="btn btn-secondary btn-sm" id="addDate" style="justify-content:center">&#128197; Date</button> <button class="btn btn-secondary btn-sm" id="addDate" style="justify-content:center">&#128197; ${t('designer.el.date')}</button>
<button class="btn btn-secondary btn-sm" id="addWeather" style="justify-content:center">&#9925; Weather</button> <button class="btn btn-secondary btn-sm" id="addWeather" style="justify-content:center">&#9925; ${t('designer.el.weather')}</button>
<button class="btn btn-secondary btn-sm" id="addTicker" style="justify-content:center">&#128240; Ticker</button> <button class="btn btn-secondary btn-sm" id="addTicker" style="justify-content:center">&#128240; ${t('designer.el.ticker')}</button>
<button class="btn btn-secondary btn-sm" id="addShape" style="justify-content:center">&#9632; Shape</button> <button class="btn btn-secondary btn-sm" id="addShape" style="justify-content:center">&#9632; ${t('designer.el.shape')}</button>
<button class="btn btn-secondary btn-sm" id="addQR" style="justify-content:center">&#9641; QR Code</button> <button class="btn btn-secondary btn-sm" id="addQR" style="justify-content:center">&#9641; ${t('designer.el.qr')}</button>
<button class="btn btn-secondary btn-sm" id="addCountdown" style="justify-content:center">&#9201; Countdown</button> <button class="btn btn-secondary btn-sm" id="addCountdown" style="justify-content:center">&#9201; ${t('designer.el.countdown')}</button>
<button class="btn btn-secondary btn-sm" id="addWebpage" style="justify-content:center">&#127760; Webpage</button> <button class="btn btn-secondary btn-sm" id="addWebpage" style="justify-content:center">&#127760; ${t('designer.el.webpage')}</button>
</div> </div>
</div> </div>
<!-- Background --> <!-- Background -->
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px"> <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:8px">Background</h4> <h4 style="font-size:13px;margin-bottom:8px">${t('designer.background')}</h4>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px"> <div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
${BACKGROUNDS.map(b => `<div style="width:30px;height:30px;border-radius:4px;cursor:pointer;border:2px solid var(--border);background:${b.value}" data-bg="${b.value}" title="${b.name}"></div>`).join('')} ${BACKGROUNDS.map(b => `<div style="width:30px;height:30px;border-radius:4px;cursor:pointer;border:2px solid var(--border);background:${b.value}" data-bg="${b.value}" title="${t('designer.bg.' + b.id)}"></div>`).join('')}
</div> </div>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<input type="color" id="bgColor" value="#000000" style="flex:1;height:32px;border:none;cursor:pointer;border-radius:4px"> <input type="color" id="bgColor" value="#000000" style="flex:1;height:32px;border:none;cursor:pointer;border-radius:4px">
<button class="btn btn-secondary btn-sm" id="bgImageBtn">Image</button> <button class="btn btn-secondary btn-sm" id="bgImageBtn">${t('designer.bg_image')}</button>
</div> </div>
<input type="file" id="bgImageInput" style="display:none" accept="image/*"> <input type="file" id="bgImageInput" style="display:none" accept="image/*">
</div> </div>
<!-- Properties --> <!-- Properties -->
<div id="propPanel" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px;display:none"> <div id="propPanel" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px;display:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h4 style="font-size:13px">Properties</h4> <h4 style="font-size:13px">${t('designer.properties')}</h4>
<button class="btn btn-danger btn-sm" id="deleteEl">Delete</button> <button class="btn btn-danger btn-sm" id="deleteEl">${t('common.delete')}</button>
</div> </div>
<div id="propFields"></div> <div id="propFields"></div>
</div> </div>
<!-- Layers --> <!-- Layers -->
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px"> <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:8px">Layers</h4> <h4 style="font-size:13px;margin-bottom:8px">${t('designer.layers')}</h4>
<div id="layerList" style="font-size:12px"></div> <div id="layerList" style="font-size:12px"></div>
</div> </div>
</div> </div>
@ -107,9 +124,40 @@ export function render(container) {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
// AI generate (#41): prompt -> validated design spec -> load onto the canvas.
document.getElementById('aiSettingsBtn').onclick = openAiSettings;
const aiGenBtn = document.getElementById('aiGenerateBtn');
aiGenBtn.onclick = async () => {
const prompt = document.getElementById('aiPrompt').value.trim();
const status = document.getElementById('aiStatus');
if (!prompt) { status.textContent = t('designer.ai.need_prompt'); return; }
aiGenBtn.disabled = true; aiGenBtn.textContent = t('designer.ai.generating');
status.textContent = t('designer.ai.contacting');
try {
const design = await api.aiGenerateDesign(prompt);
elements = []; selectedIdx = -1;
if (design.backgroundImage) {
bgImageDataUrl = design.backgroundImage; // AI-generated backdrop
if (design.background) bgValue = design.background; // kept as fallback
} else if (design.background) {
bgValue = design.background; bgImageDataUrl = null;
const bc = document.getElementById('bgColor'); if (bc) bc.value = design.background;
}
(design.elements || []).forEach(el => elements.push(el));
redraw();
status.textContent = design.image_warning
? t('designer.ai.done_imgwarn', { n: (design.elements || []).length })
: t('designer.ai.done', { n: (design.elements || []).length });
} catch (err) {
status.textContent = (err && err.message) || t('designer.ai.failed');
} finally {
aiGenBtn.disabled = false; aiGenBtn.textContent = t('designer.ai.generate');
}
};
// Add element handlers // Add element handlers
document.getElementById('addText').onclick = () => addElement({ type: 'text', x: 10, y: 60, text: 'Your text here', fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', bold: false, shadow: false }); document.getElementById('addText').onclick = () => addElement({ type: 'text', x: 10, y: 60, text: t('designer.default.text'), fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', bold: false, shadow: false });
document.getElementById('addHeading').onclick = () => addElement({ type: 'text', x: 5, y: 5, text: 'HEADING', fontSize: 64, fontFamily: 'Impact', color: '#FFFFFF', bold: true, shadow: true }); document.getElementById('addHeading').onclick = () => addElement({ type: 'text', x: 5, y: 5, text: t('designer.default.heading'), fontSize: 64, fontFamily: 'Impact', color: '#FFFFFF', bold: true, shadow: true });
document.getElementById('addImage').onclick = () => { document.getElementById('addImage').onclick = () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*';
input.onchange = () => { input.onchange = () => {
@ -120,30 +168,30 @@ export function render(container) {
input.click(); input.click();
}; };
document.getElementById('addVideo').onclick = () => { document.getElementById('addVideo').onclick = () => {
const url = prompt('Video URL (MP4):'); const url = prompt(t('designer.prompt.video_url'));
if (url) addElement({ type: 'video', x: 5, y: 5, width: 50, height: 50, src: url, muted: true, loop: true }); if (url) addElement({ type: 'video', x: 5, y: 5, width: 50, height: 50, src: url, muted: true, loop: true });
}; };
document.getElementById('addClock').onclick = () => addElement({ type: 'clock', x: 60, y: 5, fontSize: 48, fontFamily: 'Arial', color: '#FFFFFF', format: '12h', showSeconds: true, shadow: true }); document.getElementById('addClock').onclick = () => addElement({ type: 'clock', x: 60, y: 5, fontSize: 48, fontFamily: 'Arial', color: '#FFFFFF', format: '12h', showSeconds: true, shadow: true });
document.getElementById('addDate').onclick = () => addElement({ type: 'date', x: 60, y: 20, fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', shadow: false }); document.getElementById('addDate').onclick = () => addElement({ type: 'date', x: 60, y: 20, fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', shadow: false });
document.getElementById('addWeather').onclick = () => { document.getElementById('addWeather').onclick = () => {
const location = prompt('City, State:', 'Milwaukee, WI'); const location = prompt(t('designer.prompt.weather_location'), 'Milwaukee, WI');
if (location) addElement({ type: 'weather', x: 5, y: 70, fontSize: 36, color: '#FFFFFF', location, units: 'imperial' }); if (location) addElement({ type: 'weather', x: 5, y: 70, fontSize: 36, color: '#FFFFFF', location, units: 'imperial' });
}; };
document.getElementById('addTicker').onclick = () => { document.getElementById('addTicker').onclick = () => {
const url = prompt('RSS Feed URL:', 'https://feeds.bbci.co.uk/news/rss.xml'); const url = prompt(t('designer.prompt.rss_url'), 'https://feeds.bbci.co.uk/news/rss.xml');
if (url) addElement({ type: 'ticker', x: 0, y: 90, width: 100, height: 10, feedUrl: url, speed: 30, fontSize: 20, color: '#FFFFFF', bgColor: 'rgba(0,0,0,0.7)' }); if (url) addElement({ type: 'ticker', x: 0, y: 90, width: 100, height: 10, feedUrl: url, speed: 30, fontSize: 20, color: '#FFFFFF', bgColor: 'rgba(0,0,0,0.7)' });
}; };
document.getElementById('addShape').onclick = () => addElement({ type: 'shape', x: 20, y: 20, width: 30, height: 20, color: '#3b82f6', opacity: 0.7, radius: 8, shape: 'rect' }); document.getElementById('addShape').onclick = () => addElement({ type: 'shape', x: 20, y: 20, width: 30, height: 20, color: '#3b82f6', opacity: 0.7, radius: 8, shape: 'rect' });
document.getElementById('addQR').onclick = () => { document.getElementById('addQR').onclick = () => {
const data = prompt('QR Code URL:', 'https://example.com'); const data = prompt(t('designer.prompt.qr_url'), 'https://example.com');
if (data) addElement({ type: 'qr', x: 80, y: 70, size: 15, data, fgColor: '#FFFFFF', bgColor: '#000000' }); if (data) addElement({ type: 'qr', x: 80, y: 70, size: 15, data, fgColor: '#FFFFFF', bgColor: '#000000' });
}; };
document.getElementById('addCountdown').onclick = () => { document.getElementById('addCountdown').onclick = () => {
const target = prompt('Target date (YYYY-MM-DD):', '2026-04-01'); const target = prompt(t('designer.prompt.countdown_date'), '2026-04-01');
if (target) addElement({ type: 'countdown', x: 20, y: 40, fontSize: 48, color: '#FFFFFF', targetDate: target, label: 'Coming Soon' }); if (target) addElement({ type: 'countdown', x: 20, y: 40, fontSize: 48, color: '#FFFFFF', targetDate: target, label: t('designer.default.coming_soon') });
}; };
document.getElementById('addWebpage').onclick = () => { document.getElementById('addWebpage').onclick = () => {
const url = prompt('Webpage URL:'); const url = prompt(t('designer.prompt.webpage_url'));
if (url) addElement({ type: 'webpage', x: 5, y: 5, width: 40, height: 40, url }); if (url) addElement({ type: 'webpage', x: 5, y: 5, width: 40, height: 40, url });
}; };
@ -159,10 +207,10 @@ export function render(container) {
const res = await fetch('/api/widgets', { const res = await fetch('/api/widgets', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ widget_type: 'text', name: `Design ${new Date().toLocaleDateString()}`, config: { html: generateInnerHTML(), css: '', background: bgValue } }) body: JSON.stringify({ widget_type: 'text', name: t('designer.widget_name', { date: new Date().toLocaleDateString() }), config: { html: generateInnerHTML(), css: '', background: bgValue } })
}); });
if (res.ok) showToast('Published as widget! Assign it to a layout zone.', 'success'); if (res.ok) showToast(t('designer.toast.published'), 'success');
else showToast('Publish failed', 'error'); else showToast(t('designer.toast.publish_failed'), 'error');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };
@ -207,7 +255,7 @@ export function render(container) {
} }
const link = document.createElement('a'); const link = document.createElement('a');
link.download = 'signage-design.png'; link.href = canvas.toDataURL('image/png'); link.click(); link.download = 'signage-design.png'; link.href = canvas.toDataURL('image/png'); link.click();
} catch (err) { showToast('Export failed: ' + err.message, 'error'); } } catch (err) { showToast(t('designer.toast.export_failed', { error: err.message }), 'error'); }
}; };
// Load saved design // Load saved design
@ -222,8 +270,8 @@ export function render(container) {
bgValue = data.bgValue || '#000'; bgValue = data.bgValue || '#000';
bgImageDataUrl = data.bgImageDataUrl || null; bgImageDataUrl = data.bgImageDataUrl || null;
redraw(); redraw();
showToast('Design loaded', 'success'); showToast(t('designer.toast.loaded'), 'success');
} catch { showToast('Invalid design file', 'error'); } } catch { showToast(t('designer.toast.invalid_file'), 'error'); }
}; };
reader.readAsText(input.files[0]); reader.readAsText(input.files[0]);
}; };
@ -270,6 +318,110 @@ function addElement(el) {
redraw(); redraw();
} }
// #41: per-workspace AI endpoint config (BYO OpenAI-compatible endpoint + key).
async function openAiSettings() {
let cur = {};
try { cur = await api.aiGetSettings(); } catch { /* show empty form */ }
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal" style="max-width:520px;width:95vw">
<div class="modal-header">
<h3>${t('designer.ai.settings_title')}</h3>
<button class="btn-icon" data-ai-close aria-label="Close"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="modal-body">
<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('designer.ai.settings_desc')}</p>
<div class="form-group"><label>${t('designer.ai.base_url')}</label>
<input id="aiBaseUrl" class="input" value="${esc(cur.base_url || '')}" placeholder="https://api.openai.com/v1 · http://localhost:11434/v1" style="width:100%"></div>
<div class="form-group"><label>${t('designer.ai.model')}</label>
<div style="display:flex;gap:6px">
<input id="aiModel" class="input" list="aiModelList" value="${esc(cur.model || '')}" placeholder="gpt-4o-mini · llama3.1:8b" style="flex:1" autocomplete="off">
<button class="btn btn-secondary btn-sm" id="aiLoadModels" type="button" style="white-space:nowrap">${t('designer.ai.load_models')}</button>
</div>
<datalist id="aiModelList"></datalist>
<div id="aiModelMsg" style="font-size:11px;color:var(--text-muted);margin-top:4px"></div></div>
<div class="form-group"><label>${t('designer.ai.api_key')}</label>
<input id="aiKey" class="input" type="password" autocomplete="off" placeholder="${cur.has_key ? t('designer.ai.key_set') : t('designer.ai.key_placeholder')}" style="width:100%">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('designer.ai.key_hint')}</div></div>
<hr style="border:none;border-top:1px solid var(--border);margin:14px 0 10px">
<h4 style="font-size:13px;margin-bottom:4px">${t('designer.ai.images_title')}</h4>
<p style="font-size:11px;color:var(--text-muted);margin-bottom:8px">${t('designer.ai.images_desc')}</p>
<div class="form-group"><label>${t('designer.ai.image_provider')}</label>
<select id="aiImageProvider" class="input" style="width:100%">
<option value="" ${!cur.image_provider ? 'selected' : ''}>${t('designer.ai.image_off')}</option>
<option value="sdcpp" ${cur.image_provider === 'sdcpp' ? 'selected' : ''}>Stable Diffusion local (sd.cpp)</option>
<option value="openai" ${cur.image_provider === 'openai' ? 'selected' : ''}>OpenAI / OpenAI-compatible</option>
<option value="comfyui" ${cur.image_provider === 'comfyui' ? 'selected' : ''}>ComfyUI</option>
</select></div>
<div class="form-group"><label>${t('designer.ai.image_base_url')}</label>
<input id="aiImageBaseUrl" class="input" value="${esc(cur.image_base_url || '')}" placeholder="http://localhost:8080/v1 · http://localhost:8188" style="width:100%"></div>
<div class="form-group"><label>${t('designer.ai.image_model')}</label>
<input id="aiImageModel" class="input" value="${esc(cur.image_model || '')}" placeholder="${t('designer.ai.image_model_ph')}" style="width:100%"></div>
<div class="form-group"><label>${t('designer.ai.image_api_key')}</label>
<input id="aiImageKey" class="input" type="password" autocomplete="off" placeholder="${cur.has_image_key ? t('designer.ai.key_set') : t('designer.ai.image_key_ph')}" style="width:100%">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('designer.ai.image_key_hint')}</div></div>
<div id="aiSettingsErr" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-ai-close>${t('common.cancel')}</button>
<button class="btn btn-primary" id="aiSaveSettings">${t('common.save')}</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelectorAll('[data-ai-close]').forEach(b => b.onclick = close);
overlay.onclick = (e) => { if (e.target === overlay) close(); };
// Load the model list from the entered endpoint into the dropdown.
overlay.querySelector('#aiLoadModels').onclick = async () => {
const msg = overlay.querySelector('#aiModelMsg');
const base_url = overlay.querySelector('#aiBaseUrl').value.trim();
if (!base_url) { msg.style.color = 'var(--danger)'; msg.textContent = t('designer.ai.need_base_url'); return; }
const btn = overlay.querySelector('#aiLoadModels');
btn.disabled = true;
msg.style.color = 'var(--text-muted)'; msg.textContent = t('designer.ai.loading_models');
try {
const r = await api.aiListModels(base_url, overlay.querySelector('#aiKey').value || undefined);
const models = r.models || [];
overlay.querySelector('#aiModelList').innerHTML = models.map(m => `<option value="${esc(m)}"></option>`).join('');
const modelInput = overlay.querySelector('#aiModel');
if (models.length && !modelInput.value) modelInput.value = models[0];
msg.textContent = t('designer.ai.models_loaded', { n: models.length });
} catch (e2) {
msg.style.color = 'var(--danger)'; msg.textContent = (e2 && e2.message) || t('designer.ai.models_failed');
} finally {
btn.disabled = false;
}
};
overlay.querySelector('#aiSaveSettings').onclick = async () => {
const errEl = overlay.querySelector('#aiSettingsErr');
errEl.style.display = 'none';
const data = {
base_url: overlay.querySelector('#aiBaseUrl').value.trim(),
model: overlay.querySelector('#aiModel').value.trim(),
image_provider: overlay.querySelector('#aiImageProvider').value,
image_base_url: overlay.querySelector('#aiImageBaseUrl').value.trim(),
image_model: overlay.querySelector('#aiImageModel').value.trim(),
};
const key = overlay.querySelector('#aiKey').value;
if (key) data.api_key = key;
const imgKey = overlay.querySelector('#aiImageKey').value;
if (imgKey) data.image_api_key = imgKey;
try {
await api.aiSaveSettings(data);
showToast(t('designer.ai.saved'), 'success');
close();
} catch (e2) {
errEl.textContent = (e2 && e2.message) || t('designer.ai.save_failed');
errEl.style.display = 'block';
}
};
}
function getBounds(el) { function getBounds(el) {
const w = el.width || el.size || (el.fontSize ? el.fontSize * 0.6 * (el.text?.length || 8) / 100 * 100 : 20); const w = el.width || el.size || (el.fontSize ? el.fontSize * 0.6 * (el.text?.length || 8) / 100 * 100 : 20);
const h = el.height || el.size || (el.fontSize ? el.fontSize * 1.2 / 100 * 100 : 10); const h = el.height || el.size || (el.fontSize ? el.fontSize * 1.2 / 100 * 100 : 10);
@ -297,13 +449,13 @@ function redraw() {
switch (el.type) { switch (el.type) {
case 'text': case 'text':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap;${border}${cursor}" data-idx="${i}">${el.text}</div>`; html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap;${border}${cursor}" data-idx="${i}">${el.text}</div>`;
break; break;
case 'clock': case 'clock':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};font-weight:bold;${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="clock_${i}"></div>`; html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};font-weight:bold;${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="clock_${i}"></div>`;
break; break;
case 'date': case 'date':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="date_${i}"></div>`; html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;font-family:${el.fontFamily};color:${el.color};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="date_${i}"></div>`;
break; break;
case 'image': case 'image':
html += `<img src="${el.src}" style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;object-fit:contain;${border}${cursor}" data-idx="${i}" draggable="false">`; html += `<img src="${el.src}" style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;object-fit:contain;${border}${cursor}" data-idx="${i}" draggable="false">`;
@ -315,23 +467,23 @@ function redraw() {
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.color};opacity:${el.opacity};border-radius:${el.radius || 0}px;${el.shape === 'circle' ? 'border-radius:50%;' : ''}${border}${cursor}" data-idx="${i}"></div>`; html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.color};opacity:${el.opacity};border-radius:${el.radius || 0}px;${el.shape === 'circle' ? 'border-radius:50%;' : ''}${border}${cursor}" data-idx="${i}"></div>`;
break; break;
case 'weather': case 'weather':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;color:${el.color};${border}${cursor}" data-idx="${i}" id="weather_${i}">&#9925; Loading...</div>`; html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}cqw;color:${el.color};${border}${cursor}" data-idx="${i}" id="weather_${i}">&#9925; ${t('common.loading')}</div>`;
break; break;
case 'ticker': case 'ticker':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.bgColor};overflow:hidden;display:flex;align-items:center;${border}" data-idx="${i}"> html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.bgColor};overflow:hidden;display:flex;align-items:center;${border}" data-idx="${i}">
<div style="white-space:nowrap;animation:ticker ${el.speed || 30}s linear infinite;font-size:${el.fontSize / 10}vw;color:${el.color}" id="ticker_${i}">Loading news...</div> <div style="white-space:nowrap;animation:ticker ${el.speed || 30}s linear infinite;font-size:${el.fontSize / 10}cqw;color:${el.color}" id="ticker_${i}">${t('designer.loading_news')}</div>
</div>`; </div>`;
break; break;
case 'qr': case 'qr':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.size}%;aspect-ratio:1;background:${el.bgColor};display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:8px;${border}${cursor}" data-idx="${i}"> html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.size}%;aspect-ratio:1;background:${el.bgColor};display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:8px;${border}${cursor}" data-idx="${i}">
<div style="font-size:1.5vw;color:${el.fgColor};font-weight:bold">QR CODE</div> <div style="font-size:1.5vw;color:${el.fgColor};font-weight:bold">${t('designer.qr_label')}</div>
<div style="font-size:0.8vw;color:${el.fgColor};opacity:0.7;margin-top:4px">${el.data?.slice(0, 25)}</div> <div style="font-size:0.8vw;color:${el.fgColor};opacity:0.7;margin-top:4px">${el.data?.slice(0, 25)}</div>
</div>`; </div>`;
break; break;
case 'countdown': case 'countdown':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color};${border}${cursor}" data-idx="${i}"> html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color};${border}${cursor}" data-idx="${i}">
<div style="font-size:${el.fontSize / 15}vw;opacity:0.8">${el.label || ''}</div> <div style="font-size:${el.fontSize / 15}cqw;opacity:0.8">${el.label || ''}</div>
<div style="font-size:${el.fontSize / 10}vw;font-weight:bold" id="countdown_${i}"></div> <div style="font-size:${el.fontSize / 10}cqw;font-weight:bold" id="countdown_${i}"></div>
</div>`; </div>`;
break; break;
case 'webpage': case 'webpage':
@ -378,7 +530,7 @@ function updateDynamic() {
if (cdEl && el.targetDate) { if (cdEl && el.targetDate) {
const update = () => { const update = () => {
const diff = new Date(el.targetDate) - new Date(); const diff = new Date(el.targetDate) - new Date();
if (diff <= 0) { cdEl.textContent = 'NOW!'; return; } if (diff <= 0) { cdEl.textContent = t('designer.countdown_now'); return; }
const days = Math.floor(diff / 86400000); const days = Math.floor(diff / 86400000);
const hours = Math.floor((diff % 86400000) / 3600000); const hours = Math.floor((diff % 86400000) / 3600000);
const mins = Math.floor((diff % 3600000) / 60000); const mins = Math.floor((diff % 3600000) / 60000);
@ -397,15 +549,15 @@ function updateDynamic() {
const temp = el.units === 'metric' ? cur.temp_C + '°C' : cur.temp_F + '°F'; const temp = el.units === 'metric' ? cur.temp_C + '°C' : cur.temp_F + '°F';
wEl.textContent = `${temp} ${cur.weatherDesc?.[0]?.value || ''}`; wEl.textContent = `${temp} ${cur.weatherDesc?.[0]?.value || ''}`;
} }
}).catch(() => { wEl.textContent = '&#9925; ' + el.location; }); }).catch(() => { wEl.textContent = ' ' + el.location; });
} }
} }
if (el.type === 'ticker') { if (el.type === 'ticker') {
const tEl = document.getElementById(`ticker_${i}`); const tEl = document.getElementById(`ticker_${i}`);
if (tEl && el.feedUrl) { if (tEl && el.feedUrl) {
fetch(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(el.feedUrl)}`).then(r => r.json()).then(d => { fetch(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(el.feedUrl)}`).then(r => r.json()).then(d => {
tEl.textContent = (d.items || []).map(item => item.title).join(' • ') || 'No items'; tEl.textContent = (d.items || []).map(item => item.title).join(' • ') || t('designer.no_items');
}).catch(() => { tEl.textContent = 'Feed unavailable'; }); }).catch(() => { tEl.textContent = t('designer.feed_unavailable'); });
} }
} }
}); });
@ -426,42 +578,42 @@ function updateProps() {
</div>`; </div>`;
if (el.type === 'text') { if (el.type === 'text') {
html += `<div class="form-group"><label>Text</label><input type="text" class="input" value="${el.text}" data-prop="text"></div> html += `<div class="form-group"><label>${t('designer.prop.text')}</label><input type="text" class="input" value="${el.text}" data-prop="text"></div>
<div class="form-group"><label>Size</label><input type="range" min="8" max="120" value="${el.fontSize}" data-prop="fontSize" style="width:100%"><span style="font-size:11px;color:var(--text-muted)">${el.fontSize}px</span></div> <div class="form-group"><label>${t('designer.prop.size')}</label><input type="range" min="8" max="120" value="${el.fontSize}" data-prop="fontSize" style="width:100%"><span style="font-size:11px;color:var(--text-muted)">${el.fontSize}px</span></div>
<div class="form-group"><label>Font</label><select class="input" style="background:var(--bg-input)" data-prop="fontFamily">${FONTS.map(f => `<option ${f === el.fontFamily ? 'selected' : ''}>${f}</option>`).join('')}</select></div> <div class="form-group"><label>${t('designer.prop.font')}</label><select class="input" style="background:var(--bg-input)" data-prop="fontFamily">${FONTS.map(f => `<option ${f === el.fontFamily ? 'selected' : ''}>${f}</option>`).join('')}</select></div>
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none;cursor:pointer"></div> <div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none;cursor:pointer"></div>
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.bold ? 'checked' : ''} data-prop="bold"> Bold</label> <label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.bold ? 'checked' : ''} data-prop="bold"> ${t('designer.prop.bold')}</label>
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.shadow ? 'checked' : ''} data-prop="shadow"> Shadow</label>`; <label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.shadow ? 'checked' : ''} data-prop="shadow"> ${t('designer.prop.shadow')}</label>`;
} else if (el.type === 'clock') { } else if (el.type === 'clock') {
html += `<div class="form-group"><label>Size</label><input type="range" min="16" max="120" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div> html += `<div class="form-group"><label>${t('designer.prop.size')}</label><input type="range" min="16" max="120" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div> <div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>
<div class="form-group"><label>Format</label><select class="input" style="background:var(--bg-input)" data-prop="format"><option ${el.format === '12h' ? 'selected' : ''} value="12h">12h</option><option ${el.format === '24h' ? 'selected' : ''} value="24h">24h</option></select></div> <div class="form-group"><label>${t('designer.prop.format')}</label><select class="input" style="background:var(--bg-input)" data-prop="format"><option ${el.format === '12h' ? 'selected' : ''} value="12h">12h</option><option ${el.format === '24h' ? 'selected' : ''} value="24h">24h</option></select></div>
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.showSeconds ? 'checked' : ''} data-prop="showSeconds"> Show seconds</label>`; <label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.showSeconds ? 'checked' : ''} data-prop="showSeconds"> ${t('designer.prop.show_seconds')}</label>`;
} else if (el.type === 'image' || el.type === 'video' || el.type === 'webpage') { } else if (el.type === 'image' || el.type === 'video' || el.type === 'webpage') {
html += `<div style="display:flex;gap:6px"><div class="form-group" style="flex:1;margin:0"><label>W%</label><input type="number" class="input" value="${Math.round(el.width)}" data-prop="width"></div> html += `<div style="display:flex;gap:6px"><div class="form-group" style="flex:1;margin:0"><label>W%</label><input type="number" class="input" value="${Math.round(el.width)}" data-prop="width"></div>
<div class="form-group" style="flex:1;margin:0"><label>H%</label><input type="number" class="input" value="${Math.round(el.height)}" data-prop="height"></div></div>`; <div class="form-group" style="flex:1;margin:0"><label>H%</label><input type="number" class="input" value="${Math.round(el.height)}" data-prop="height"></div></div>`;
if (el.type === 'video') html += `<label style="font-size:12px;display:flex;gap:6px;margin:8px 0"><input type="checkbox" ${el.muted ? 'checked' : ''} data-prop="muted"> Muted</label> if (el.type === 'video') html += `<label style="font-size:12px;display:flex;gap:6px;margin:8px 0"><input type="checkbox" ${el.muted ? 'checked' : ''} data-prop="muted"> ${t('designer.prop.muted')}</label>
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.loop ? 'checked' : ''} data-prop="loop"> Loop</label>`; <label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.loop ? 'checked' : ''} data-prop="loop"> ${t('designer.prop.loop')}</label>`;
} else if (el.type === 'shape') { } else if (el.type === 'shape') {
html += `<div style="display:flex;gap:6px"><div class="form-group" style="flex:1;margin:0"><label>W%</label><input type="number" class="input" value="${Math.round(el.width)}" data-prop="width"></div> html += `<div style="display:flex;gap:6px"><div class="form-group" style="flex:1;margin:0"><label>W%</label><input type="number" class="input" value="${Math.round(el.width)}" data-prop="width"></div>
<div class="form-group" style="flex:1;margin:0"><label>H%</label><input type="number" class="input" value="${Math.round(el.height)}" data-prop="height"></div></div> <div class="form-group" style="flex:1;margin:0"><label>H%</label><input type="number" class="input" value="${Math.round(el.height)}" data-prop="height"></div></div>
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div> <div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>
<div class="form-group"><label>Opacity</label><input type="range" min="0" max="1" step="0.1" value="${el.opacity}" data-prop="opacity" style="width:100%"></div> <div class="form-group"><label>${t('designer.prop.opacity')}</label><input type="range" min="0" max="1" step="0.1" value="${el.opacity}" data-prop="opacity" style="width:100%"></div>
<div class="form-group"><label>Shape</label><select class="input" style="background:var(--bg-input)" data-prop="shape"><option ${el.shape === 'rect' ? 'selected' : ''}>rect</option><option ${el.shape === 'circle' ? 'selected' : ''}>circle</option></select></div>`; <div class="form-group"><label>${t('designer.prop.shape')}</label><select class="input" style="background:var(--bg-input)" data-prop="shape"><option ${el.shape === 'rect' ? 'selected' : ''}>rect</option><option ${el.shape === 'circle' ? 'selected' : ''}>circle</option></select></div>`;
} else if (el.type === 'weather') { } else if (el.type === 'weather') {
html += `<div class="form-group"><label>Location</label><input type="text" class="input" value="${el.location}" data-prop="location"></div> html += `<div class="form-group"><label>${t('designer.prop.location')}</label><input type="text" class="input" value="${el.location}" data-prop="location"></div>
<div class="form-group"><label>Size</label><input type="range" min="16" max="80" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div> <div class="form-group"><label>${t('designer.prop.size')}</label><input type="range" min="16" max="80" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`; <div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`;
} else if (el.type === 'ticker') { } else if (el.type === 'ticker') {
html += `<div class="form-group"><label>Feed URL</label><input type="text" class="input" value="${el.feedUrl}" data-prop="feedUrl"></div> html += `<div class="form-group"><label>${t('designer.prop.feed_url')}</label><input type="text" class="input" value="${el.feedUrl}" data-prop="feedUrl"></div>
<div class="form-group"><label>Speed (seconds)</label><input type="number" class="input" value="${el.speed}" data-prop="speed"></div> <div class="form-group"><label>${t('designer.prop.speed')}</label><input type="number" class="input" value="${el.speed}" data-prop="speed"></div>
<div class="form-group"><label>Text Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div> <div class="form-group"><label>${t('designer.prop.text_color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>
<div class="form-group"><label>BG Color</label><input type="text" class="input" value="${el.bgColor}" data-prop="bgColor"></div>`; <div class="form-group"><label>${t('designer.prop.bg_color')}</label><input type="text" class="input" value="${el.bgColor}" data-prop="bgColor"></div>`;
} else if (el.type === 'countdown') { } else if (el.type === 'countdown') {
html += `<div class="form-group"><label>Target Date</label><input type="date" class="input" value="${el.targetDate}" data-prop="targetDate"></div> html += `<div class="form-group"><label>${t('designer.prop.target_date')}</label><input type="date" class="input" value="${el.targetDate}" data-prop="targetDate"></div>
<div class="form-group"><label>Label</label><input type="text" class="input" value="${el.label}" data-prop="label"></div> <div class="form-group"><label>${t('designer.prop.label')}</label><input type="text" class="input" value="${el.label}" data-prop="label"></div>
<div class="form-group"><label>Size</label><input type="range" min="16" max="100" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div> <div class="form-group"><label>${t('designer.prop.size')}</label><input type="range" min="16" max="100" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`; <div class="form-group"><label>${t('designer.prop.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`;
} }
// Save design button // Save design button
@ -470,7 +622,7 @@ function updateProps() {
a.download = 'design.json'; a.download = 'design.json';
a.href = 'data:application/json,' + encodeURIComponent(JSON.stringify({elements: ${JSON.stringify(elements)}, bgValue: '${bgValue}'})); a.href = 'data:application/json,' + encodeURIComponent(JSON.stringify({elements: ${JSON.stringify(elements)}, bgValue: '${bgValue}'}));
a.click(); a.click();
})()">Save Design File</button>`; })()">${t('designer.save_design_file')}</button>`;
fields.innerHTML = html; fields.innerHTML = html;
@ -498,7 +650,7 @@ function updateLayers() {
<span>${typeIcons[el.type] || '?'}</span> <span>${typeIcons[el.type] || '?'}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${el.text || el.type}</span> <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${el.text || el.type}</span>
</div> </div>
`).join('') || '<p style="color:var(--text-muted)">No elements yet</p>'; `).join('') || `<p style="color:var(--text-muted)">${t('designer.no_elements')}</p>`;
list.querySelectorAll('[data-layer]').forEach(el => { list.querySelectorAll('[data-layer]').forEach(el => {
el.onclick = () => { selectedIdx = parseInt(el.dataset.layer); redraw(); }; el.onclick = () => { selectedIdx = parseInt(el.dataset.layer); redraw(); };
@ -507,6 +659,9 @@ function updateLayers() {
function generateInnerHTML() { function generateInnerHTML() {
let html = ''; let html = '';
// A background image (e.g. AI-generated) is the body background in the editor;
// bake it into the published HTML as a full-cover bottom layer so it survives.
if (bgImageDataUrl) html += `<img src="${bgImageDataUrl}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover" alt="">`;
elements.forEach((el, i) => { elements.forEach((el, i) => {
// Use vw units for font sizes (same as designer preview) so output scales to any viewport // Use vw units for font sizes (same as designer preview) so output scales to any viewport
const fs = el.fontSize / 10; const fs = el.fontSize / 10;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,81 @@
// #10: forced first-login password change. When an admin provisions a user
// with must_change_password=1, route() in app.js redirects them here and blocks
// every other view until they set a new password. Reuses the same PUT /api/auth/me
// path as the Settings change-password form; on success the server clears
// must_change_password, we refresh the cached user, and return to the app.
import { api } from '../api.js';
import { t } from '../i18n.js';
import { showToast } from '../components/toast.js';
export async function render(container) {
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
<div style="width:400px;max-width:100%">
<div style="text-align:center;margin-bottom:24px">
<h1 style="font-size:22px;font-weight:700;color:var(--accent)">${t('forcepw.title')}</h1>
<p style="color:var(--text-secondary);font-size:13px;margin-top:6px">${t('forcepw.subtitle')}</p>
</div>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px">
<div class="form-group">
<label>${t('forcepw.current')}</label>
<input type="password" id="fpwCurrent" class="input" autocomplete="current-password">
</div>
<div class="form-group">
<label>${t('forcepw.new')}</label>
<input type="password" id="fpwNew" class="input" autocomplete="new-password">
</div>
<div class="form-group">
<label>${t('forcepw.confirm')}</label>
<input type="password" id="fpwConfirm" class="input" autocomplete="new-password">
</div>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('forcepw.hint')}</p>
<button class="btn btn-primary" id="fpwSubmit" style="width:100%;justify-content:center;padding:10px">${t('forcepw.submit')}</button>
<p id="fpwError" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></p>
</div>
</div>
</div>
`;
const current = container.querySelector('#fpwCurrent');
const next = container.querySelector('#fpwNew');
const confirm = container.querySelector('#fpwConfirm');
const submit = container.querySelector('#fpwSubmit');
const errorEl = container.querySelector('#fpwError');
current.focus();
const showError = (msg) => { errorEl.textContent = msg; errorEl.style.display = 'block'; };
async function doChange() {
errorEl.style.display = 'none';
const cur = current.value;
const nw = next.value;
const cf = confirm.value;
if (!cur || !nw) { showError(t('forcepw.error_required')); return; }
if (nw.length < 8) { showError(t('forcepw.error_min8')); return; }
if (nw !== cf) { showError(t('forcepw.error_mismatch')); return; }
submit.disabled = true;
submit.textContent = t('forcepw.submitting');
try {
await api.updateMe({ password: nw, current_password: cur });
// Refresh the cached user so the (now-cleared) must_change_password flag
// is reflected, then return to the app.
try {
const fresh = await api.getMe();
localStorage.setItem('user', JSON.stringify(fresh));
} catch { /* fall through; reload re-fetches */ }
showToast(t('forcepw.success'), 'success');
window.location.hash = '#/';
window.location.reload();
} catch (err) {
submit.disabled = false;
submit.textContent = t('forcepw.submit');
showError(err?.message || t('forcepw.error_generic'));
}
}
submit.addEventListener('click', doChange);
[current, next, confirm].forEach(el => el.addEventListener('keydown', (e) => { if (e.key === 'Enter') doChange(); }));
}
export function cleanup() {}

View file

@ -1,7 +1,13 @@
import { t } from '../i18n.js';
// Help guides + FAQ are documentation. Page chrome is translated; the body
// content is intentionally left in English because partial machine
// translation of multi-paragraph docs reads worse than a single source of
// truth. A native-language docs site is the right long-term answer.
export function render(container) { export function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div><h1>Help Center</h1><div class="subtitle">Quick guides and FAQ</div></div> <div><h1>${t('help.title')}</h1><div class="subtitle">${t('help.subtitle')}</div></div>
</div> </div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:32px"> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:32px">
@ -9,6 +15,7 @@ export function render(container) {
{ icon: '&#128250;', title: 'Setting Up a Display', steps: ['Download the APK or open the Web Player', 'Enter your server URL', 'Note the 6-digit pairing code', 'Click "Add Display" in the dashboard and enter the code', 'Assign content to the display\'s playlist'] }, { icon: '&#128250;', title: 'Setting Up a Display', steps: ['Download the APK or open the Web Player', 'Enter your server URL', 'Note the 6-digit pairing code', 'Click "Add Display" in the dashboard and enter the code', 'Assign content to the display\'s playlist'] },
{ icon: '&#128228;', title: 'Uploading Content', steps: ['Go to Content Library', 'Drag and drop files or click the upload area', 'Supports MP4, WebM, JPEG, PNG, GIF, WebP', 'Videos auto-detect duration and generate thumbnails', 'Use Remote URL to stream from external sources'] }, { icon: '&#128228;', title: 'Uploading Content', steps: ['Go to Content Library', 'Drag and drop files or click the upload area', 'Supports MP4, WebM, JPEG, PNG, GIF, WebP', 'Videos auto-detect duration and generate thumbnails', 'Use Remote URL to stream from external sources'] },
{ icon: '&#9881;', title: 'Using Widgets', steps: ['Go to Widgets and click "New Widget"', 'Choose a type: Clock, Weather, RSS, Text, Webpage, or Social', 'Configure the widget settings', 'Assign the widget to a device via the Playlist tab', 'Widgets render as live HTML content'] }, { icon: '&#9881;', title: 'Using Widgets', steps: ['Go to Widgets and click "New Widget"', 'Choose a type: Clock, Weather, RSS, Text, Webpage, or Social', 'Configure the widget settings', 'Assign the widget to a device via the Playlist tab', 'Widgets render as live HTML content'] },
{ icon: '&#10024;', title: 'AI Content Design', steps: ['Open Designer and click the gear on the "AI generate" panel', 'Add an OpenAI-compatible text endpoint + model (OpenAI cloud, or a local Ollama)', 'Optional: pick an image provider for AI backgrounds (OpenAI, or local Stable Diffusion / ComfyUI)', 'Type a prompt, click "Generate design", then tweak and Publish', 'Run it fully local + free — see docs/local-ai-setup.md'] },
{ icon: '&#128203;', title: 'Multi-Zone Layouts', steps: ['Go to Layouts and create a new layout or use a template', 'Drag zones to position them on the canvas', 'Resize using the corner handle', 'Assign the layout to a device in the Playlist tab', 'Each zone can show different content'] }, { icon: '&#128203;', title: 'Multi-Zone Layouts', steps: ['Go to Layouts and create a new layout or use a template', 'Drag zones to position them on the canvas', 'Resize using the corner handle', 'Assign the layout to a device in the Playlist tab', 'Each zone can show different content'] },
{ icon: '&#128197;', title: 'Content Scheduling', steps: ['Go to Schedule and select a device', 'Click "Add Schedule" to create a time slot', 'Set start/end times and recurrence rules', 'Higher priority schedules override lower ones', 'Content auto-switches based on the schedule'] }, { icon: '&#128197;', title: 'Content Scheduling', steps: ['Go to Schedule and select a device', 'Click "Add Schedule" to create a time slot', 'Set start/end times and recurrence rules', 'Higher priority schedules override lower ones', 'Content auto-switches based on the schedule'] },
{ icon: '&#128421;', title: 'Remote Control', steps: ['Go to a device\'s detail page', 'Click the "Remote Control" tab', 'Click "Start Remote" to begin streaming', 'Use the d-pad, volume, and power buttons', 'Click anywhere on the screen to simulate a tap'] }, { icon: '&#128421;', title: 'Remote Control', steps: ['Go to a device\'s detail page', 'Click the "Remote Control" tab', 'Click "Start Remote" to begin streaming', 'Use the d-pad, volume, and power buttons', 'Click anywhere on the screen to simulate a tap'] },
@ -25,7 +32,7 @@ export function render(container) {
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Frequently Asked Questions</h3> <h3>${t('help.faq')}</h3>
${[ ${[
{ q: 'What devices are supported?', a: 'Android TV/tablets (APK), Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, Fire TV, and any device with a web browser.' }, { q: 'What devices are supported?', a: 'Android TV/tablets (APK), Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, Fire TV, and any device with a web browser.' },
{ q: 'How does the free trial work?', a: 'New accounts get a 14-day free trial of the Pro plan (15 devices, all features). After 14 days, you\'re moved to the Free plan (1 device) unless you upgrade.' }, { q: 'How does the free trial work?', a: 'New accounts get a 14-day free trial of the Pro plan (15 devices, all features). After 14 days, you\'re moved to the Free plan (1 device) unless you upgrade.' },
@ -46,10 +53,10 @@ export function render(container) {
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Keyboard Shortcuts</h3> <h3>${t('help.shortcuts')}</h3>
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:13px"> <div style="display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:13px">
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">Esc</kbd> <span style="color:var(--text-secondary)">Reset web player (on player page)</span> <kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">Esc</kbd> <span style="color:var(--text-secondary)">${t('help.shortcut_esc')}</span>
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">F</kbd> <span style="color:var(--text-secondary)">Toggle fullscreen (web player)</span> <kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">F</kbd> <span style="color:var(--text-secondary)">${t('help.shortcut_f')}</span>
</div> </div>
</div> </div>
`; `;

View file

@ -1,4 +1,6 @@
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
@ -14,17 +16,17 @@ export async function render(container) {
async function renderList(container) { async function renderList(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div><h1>Kiosk Pages <span class="help-tip" data-tip="Create interactive touchscreen interfaces. Add buttons with icons and actions. Includes idle screen that shows after inactivity. Assign to devices as a widget.">?</span></h1><div class="subtitle">Create interactive touchscreen interfaces</div></div> <div><h1>${t('kiosk.title')} <span class="help-tip" data-tip="${t('kiosk.help_tip')}">?</span></h1><div class="subtitle">${t('kiosk.subtitle')}</div></div>
<button class="btn btn-primary" id="newKioskBtn"> <button class="btn btn-primary" id="newKioskBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Kiosk Page ${t('kiosk.new_page')}
</button> </button>
</div> </div>
<div class="content-grid" id="kioskGrid"></div> <div class="content-grid" id="kioskGrid"></div>
`; `;
document.getElementById('newKioskBtn').onclick = async () => { document.getElementById('newKioskBtn').onclick = async () => {
const name = prompt('Kiosk page name:'); const name = prompt(t('kiosk.prompt_name'));
if (!name) return; if (!name) return;
const page = await API('/kiosk', { method: 'POST', body: JSON.stringify({ name }) }); const page = await API('/kiosk', { method: 'POST', body: JSON.stringify({ name }) });
window.location.hash = `#/kiosk/${page.id}`; window.location.hash = `#/kiosk/${page.id}`;
@ -34,7 +36,7 @@ async function renderList(container) {
const pages = await API('/kiosk'); const pages = await API('/kiosk');
const grid = document.getElementById('kioskGrid'); const grid = document.getElementById('kioskGrid');
if (!pages.length) { if (!pages.length) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><h3>No kiosk pages yet</h3><p>Create an interactive touchscreen interface for your displays.</p></div>'; grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('kiosk.empty_title')}</h3><p>${t('kiosk.empty_desc')}</p></div>`;
return; return;
} }
grid.innerHTML = pages.map(p => ` grid.innerHTML = pages.map(p => `
@ -43,28 +45,27 @@ async function renderList(container) {
<span style="font-size:48px">&#128433;</span> <span style="font-size:48px">&#128433;</span>
</div> </div>
<div class="content-item-body"> <div class="content-item-body">
<div class="content-item-name">${p.name}</div> <div class="content-item-name">${esc(p.name)}</div>
<div class="content-item-size">Kiosk Page</div> <div class="content-item-size">${t('kiosk.label')}</div>
</div> </div>
<div class="content-item-actions"> <div class="content-item-actions">
<a href="/api/kiosk/${p.id}/render" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none" onclick="event.stopPropagation()">Preview</a> <a href="/api/kiosk/${p.id}/render" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none" onclick="event.stopPropagation()">${t('kiosk.preview')}</a>
<button class="btn btn-danger btn-sm" data-delete-kiosk="${p.id}" data-kiosk-name="${p.name}" onclick="event.stopPropagation()">Delete</button> <button class="btn btn-danger btn-sm" data-delete-kiosk="${esc(p.id)}" data-kiosk-name="${esc(p.name)}" onclick="event.stopPropagation()">${t('common.delete')}</button>
</div> </div>
</div> </div>
`).join(''); `).join('');
// Delete handler
grid.querySelectorAll('[data-delete-kiosk]').forEach(btn => { grid.querySelectorAll('[data-delete-kiosk]').forEach(btn => {
btn.onclick = async (e) => { btn.onclick = async (e) => {
e.stopPropagation(); e.stopPropagation();
const name = btn.dataset.kioskName; const name = btn.dataset.kioskName;
if (!confirm(`Delete kiosk page "${name}"? This cannot be undone.`)) return; if (!confirm(t('kiosk.confirm_delete', { name }))) return;
try { try {
await API(`/kiosk/${btn.dataset.deleteKiosk}`, { method: 'DELETE' }); await API(`/kiosk/${btn.dataset.deleteKiosk}`, { method: 'DELETE' });
showToast('Kiosk page deleted'); showToast(t('kiosk.toast.deleted'));
renderList(container); renderList(container);
} catch (err) { } catch (err) {
showToast(err.message || 'Failed to delete', 'error'); showToast(err.message || t('kiosk.toast.delete_failed'), 'error');
} }
}; };
}); });
@ -73,7 +74,7 @@ async function renderList(container) {
async function renderEditor(container, pageId) { async function renderEditor(container, pageId) {
let page; let page;
try { page = await API(`/kiosk/${pageId}`); } catch { container.innerHTML = '<div class="empty-state"><h3>Page not found</h3></div>'; return; } try { page = await API(`/kiosk/${pageId}`); } catch { container.innerHTML = `<div class="empty-state"><h3>${t('kiosk.not_found')}</h3></div>`; return; }
let config = JSON.parse(page.config || '{}'); let config = JSON.parse(page.config || '{}');
if (!config.buttons) config.buttons = []; if (!config.buttons) config.buttons = [];
@ -82,49 +83,47 @@ async function renderEditor(container, pageId) {
container.innerHTML = ` container.innerHTML = `
<a href="#/kiosk" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px"> <a href="#/kiosk" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Kiosk Pages ${t('kiosk.back')}
</a> </a>
<div class="page-header"> <div class="page-header">
<h1>${page.name}</h1> <h1>${esc(page.name)}</h1>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<a href="/api/kiosk/${pageId}/render" target="_blank" class="btn btn-secondary" style="text-decoration:none">Preview</a> <a href="/api/kiosk/${pageId}/render" target="_blank" class="btn btn-secondary" style="text-decoration:none">${t('kiosk.preview')}</a>
<button class="btn btn-primary" id="saveKioskBtn">Save</button> <button class="btn btn-primary" id="saveKioskBtn">${t('common.save')}</button>
</div> </div>
</div> </div>
<div style="display:flex;gap:20px"> <div style="display:flex;gap:20px">
<!-- Preview -->
<div style="flex:1"> <div style="flex:1">
<iframe id="kioskPreview" src="/api/kiosk/${pageId}/render" style="width:100%;aspect-ratio:16/9;border:1px solid var(--border);border-radius:var(--radius-lg)"></iframe> <iframe id="kioskPreview" src="/api/kiosk/${pageId}/render" style="width:100%;aspect-ratio:16/9;border:1px solid var(--border);border-radius:var(--radius-lg)"></iframe>
</div> </div>
<!-- Editor -->
<div style="width:320px;max-height:calc(100vh - 140px);overflow-y:auto;display:flex;flex-direction:column;gap:12px"> <div style="width:320px;max-height:calc(100vh - 140px);overflow-y:auto;display:flex;flex-direction:column;gap:12px">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px"> <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:10px">Page Settings</h4> <h4 style="font-size:13px;margin-bottom:10px">${t('kiosk.page_settings')}</h4>
<div class="form-group"><label>Title</label><input type="text" id="kTitle" class="input" value="${config.title || ''}"></div> <div class="form-group"><label>${t('kiosk.title_label')}</label><input type="text" id="kTitle" class="input" value="${esc(config.title || '')}"></div>
<div class="form-group"><label>Subtitle</label><input type="text" id="kSubtitle" class="input" value="${config.subtitle || ''}"></div> <div class="form-group"><label>${t('kiosk.subtitle_label')}</label><input type="text" id="kSubtitle" class="input" value="${esc(config.subtitle || '')}"></div>
<div class="form-group"><label>Logo URL</label><input type="text" id="kLogo" class="input" value="${config.logoUrl || ''}" placeholder="https://..."></div> <div class="form-group"><label>${t('kiosk.logo_url')}</label><input type="text" id="kLogo" class="input" value="${esc(config.logoUrl || '')}" placeholder="https://..."></div>
<div class="form-group"><label>Footer Text</label><input type="text" id="kFooter" class="input" value="${config.footer || ''}"></div> <div class="form-group"><label>${t('kiosk.footer_text')}</label><input type="text" id="kFooter" class="input" value="${esc(config.footer || '')}"></div>
<div class="form-group"><label>Idle Screen Title</label><input type="text" id="kIdleTitle" class="input" value="${config.idleTitle || 'Touch to Begin'}"></div> <div class="form-group"><label>${t('kiosk.idle_title')}</label><input type="text" id="kIdleTitle" class="input" value="${esc(config.idleTitle || t('kiosk.idle_default'))}"></div>
<div class="form-group"><label>Idle Timeout (seconds)</label><input type="number" id="kIdleTimeout" class="input" value="${config.idleTimeout || 60}"></div> <div class="form-group"><label>${t('kiosk.idle_timeout')}</label><input type="number" id="kIdleTimeout" class="input" value="${config.idleTimeout || 60}"></div>
</div> </div>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px"> <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:10px">Style</h4> <h4 style="font-size:13px;margin-bottom:10px">${t('kiosk.style')}</h4>
<div class="form-group"><label>Background</label><input type="text" id="kBg" class="input" value="${config.style?.background || '#111827'}"></div> <div class="form-group"><label>${t('kiosk.background')}</label><input type="text" id="kBg" class="input" value="${esc(config.style?.background || '#111827')}"></div>
<div class="form-group"><label>Text Color</label><input type="color" id="kTextColor" value="${config.style?.textColor || '#f1f5f9'}" style="width:100%;height:28px;border:none;cursor:pointer"></div> <div class="form-group"><label>${t('kiosk.text_color')}</label><input type="color" id="kTextColor" value="${config.style?.textColor || '#f1f5f9'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
<div class="form-group"><label>Columns</label><select id="kColumns" class="input" style="background:var(--bg-input)"> <div class="form-group"><label>${t('kiosk.columns')}</label><select id="kColumns" class="input" style="background:var(--bg-input)">
<option ${(config.style?.columns || 3) === 2 ? 'selected' : ''} value="2">2</option> <option ${(config.style?.columns || 3) === 2 ? 'selected' : ''} value="2">2</option>
<option ${(config.style?.columns || 3) === 3 ? 'selected' : ''} value="3">3</option> <option ${(config.style?.columns || 3) === 3 ? 'selected' : ''} value="3">3</option>
<option ${(config.style?.columns || 3) === 4 ? 'selected' : ''} value="4">4</option> <option ${(config.style?.columns || 3) === 4 ? 'selected' : ''} value="4">4</option>
</select></div> </select></div>
<div class="form-group"><label>Button Color</label><input type="color" id="kBtnBg" value="${config.style?.buttonBg || '#1e293b'}" style="width:100%;height:28px;border:none;cursor:pointer"></div> <div class="form-group"><label>${t('kiosk.button_color')}</label><input type="color" id="kBtnBg" value="${config.style?.buttonBg || '#1e293b'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
<div class="form-group"><label>Button Hover Color</label><input type="color" id="kBtnHover" value="${config.style?.buttonHover || '#3b82f6'}" style="width:100%;height:28px;border:none;cursor:pointer"></div> <div class="form-group"><label>${t('kiosk.button_hover')}</label><input type="color" id="kBtnHover" value="${config.style?.buttonHover || '#3b82f6'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
</div> </div>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px"> <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h4 style="font-size:13px">Buttons</h4> <h4 style="font-size:13px">${t('kiosk.buttons')}</h4>
<button class="btn btn-secondary btn-sm" id="addBtnBtn">+ Add</button> <button class="btn btn-secondary btn-sm" id="addBtnBtn">${t('kiosk.add_btn')}</button>
</div> </div>
<div id="buttonList"></div> <div id="buttonList"></div>
</div> </div>
@ -137,23 +136,22 @@ async function renderEditor(container, pageId) {
list.innerHTML = config.buttons.map((btn, i) => ` list.innerHTML = config.buttons.map((btn, i) => `
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:8px;margin-bottom:6px"> <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:8px;margin-bottom:6px">
<div style="display:flex;gap:6px;margin-bottom:6px"> <div style="display:flex;gap:6px;margin-bottom:6px">
<input type="text" class="input" value="${btn.icon || ''}" placeholder="Emoji" style="width:50px;text-align:center" data-btn="${i}" data-field="icon"> <input type="text" class="input" value="${esc(btn.icon || '')}" placeholder="${t('kiosk.icon_placeholder')}" style="width:50px;text-align:center" data-btn="${i}" data-field="icon">
<input type="text" class="input" value="${btn.label || ''}" placeholder="Label" style="flex:1" data-btn="${i}" data-field="label"> <input type="text" class="input" value="${esc(btn.label || '')}" placeholder="${t('kiosk.label_placeholder')}" style="flex:1" data-btn="${i}" data-field="label">
</div> </div>
<input type="text" class="input" value="${btn.sublabel || ''}" placeholder="Sublabel" style="font-size:12px;margin-bottom:4px" data-btn="${i}" data-field="sublabel"> <input type="text" class="input" value="${esc(btn.sublabel || '')}" placeholder="${t('kiosk.sublabel_placeholder')}" style="font-size:12px;margin-bottom:4px" data-btn="${i}" data-field="sublabel">
<div style="display:flex;gap:6px;align-items:center"> <div style="display:flex;gap:6px;align-items:center">
<select class="input" style="background:var(--bg-input);font-size:11px;flex:1" data-btn="${i}" data-field="action"> <select class="input" style="background:var(--bg-input);font-size:11px;flex:1" data-btn="${i}" data-field="action">
<option value="" ${!btn.action ? 'selected' : ''}>No action</option> <option value="" ${!btn.action ? 'selected' : ''}>${t('kiosk.action_none')}</option>
<option value="url" ${btn.action === 'url' ? 'selected' : ''}>Open URL</option> <option value="url" ${btn.action === 'url' ? 'selected' : ''}>${t('kiosk.action_url')}</option>
<option value="page" ${btn.action === 'page' ? 'selected' : ''}>Go to page</option> <option value="page" ${btn.action === 'page' ? 'selected' : ''}>${t('kiosk.action_page')}</option>
</select> </select>
<button class="btn-icon" style="color:var(--danger)" data-remove-btn="${i}" title="Remove">&#10005;</button> <button class="btn-icon" style="color:var(--danger)" data-remove-btn="${i}" title="${t('common.delete')}">&#10005;</button>
</div> </div>
<input type="text" class="input" value="${btn.url || btn.page || ''}" placeholder="URL or page" style="font-size:11px;margin-top:4px" data-btn="${i}" data-field="url"> <input type="text" class="input" value="${esc(btn.url || btn.page || '')}" placeholder="${t('kiosk.url_placeholder')}" style="font-size:11px;margin-top:4px" data-btn="${i}" data-field="url">
</div> </div>
`).join('') || '<p style="color:var(--text-muted);font-size:12px">No buttons yet</p>'; `).join('') || `<p style="color:var(--text-muted);font-size:12px">${t('kiosk.no_buttons')}</p>`;
// Bind inputs
list.querySelectorAll('[data-btn]').forEach(input => { list.querySelectorAll('[data-btn]').forEach(input => {
input.oninput = () => { input.oninput = () => {
const idx = parseInt(input.dataset.btn); const idx = parseInt(input.dataset.btn);
@ -168,7 +166,7 @@ async function renderEditor(container, pageId) {
} }
document.getElementById('addBtnBtn').onclick = () => { document.getElementById('addBtnBtn').onclick = () => {
config.buttons.push({ label: 'New Button', sublabel: '', icon: '&#11088;', action: '', url: '' }); config.buttons.push({ label: t('kiosk.new_button'), sublabel: '', icon: '&#11088;', action: '', url: '' });
renderButtons(); renderButtons();
}; };
@ -190,7 +188,7 @@ async function renderEditor(container, pageId) {
try { try {
await API(`/kiosk/${pageId}`, { method: 'PUT', body: JSON.stringify({ config }) }); await API(`/kiosk/${pageId}`, { method: 'PUT', body: JSON.stringify({ config }) });
showToast('Kiosk page saved', 'success'); showToast(t('kiosk.toast.saved'), 'success');
document.getElementById('kioskPreview').src = `/api/kiosk/${pageId}/render?t=${Date.now()}`; document.getElementById('kioskPreview').src = `/api/kiosk/${pageId}/render?t=${Date.now()}`;
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };

View file

@ -1,5 +1,7 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { t, tn } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
@ -15,22 +17,22 @@ export async function render(container) {
async function renderList(container) { async function renderList(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div><h1>Layouts <span class="help-tip" data-tip="Create multi-zone screen layouts. Use templates or build custom ones. Drag zones to position, resize with corner handle. Assign layouts to devices in the Playlist tab.">?</span></h1><div class="subtitle">Screen layouts and templates</div></div> <div><h1>${t('layout.title')} <span class="help-tip" data-tip="${t('layout.help_tip')}">?</span></h1><div class="subtitle">${t('layout.subtitle')}</div></div>
<button class="btn btn-primary" id="newLayoutBtn"> <button class="btn btn-primary" id="newLayoutBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Layout ${t('layout.new_layout')}
</button> </button>
</div> </div>
<h3 style="margin-bottom:12px;font-size:14px;color:var(--text-secondary)">Templates</h3> <h3 style="margin-bottom:12px;font-size:14px;color:var(--text-secondary)">${t('layout.templates')}</h3>
<div class="content-grid" id="templateGrid"></div> <div class="content-grid" id="templateGrid"></div>
<h3 style="margin:24px 0 12px;font-size:14px;color:var(--text-secondary)">My Layouts</h3> <h3 style="margin:24px 0 12px;font-size:14px;color:var(--text-secondary)">${t('layout.my_layouts')}</h3>
<div class="content-grid" id="layoutGrid"></div> <div class="content-grid" id="layoutGrid"></div>
`; `;
document.getElementById('newLayoutBtn').onclick = async () => { document.getElementById('newLayoutBtn').onclick = async () => {
const name = prompt('Layout name:'); const name = prompt(t('layout.prompt_name'));
if (!name) return; if (!name) return;
const layout = await API('/layouts', { method: 'POST', body: JSON.stringify({ name, zones: [{ name: 'Main', x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 100 }] }) }); const layout = await API('/layouts', { method: 'POST', body: JSON.stringify({ name, zones: [{ name: t('layout.default_zone_name'), x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 100 }] }) });
window.location.hash = `#/layout/${layout.id}`; window.location.hash = `#/layout/${layout.id}`;
}; };
@ -41,9 +43,8 @@ async function renderList(container) {
document.getElementById('templateGrid').innerHTML = templates.map(l => renderLayoutCard(l, true)).join(''); document.getElementById('templateGrid').innerHTML = templates.map(l => renderLayoutCard(l, true)).join('');
document.getElementById('layoutGrid').innerHTML = custom.length ? custom.map(l => renderLayoutCard(l, false)).join('') : document.getElementById('layoutGrid').innerHTML = custom.length ? custom.map(l => renderLayoutCard(l, false)).join('') :
'<div class="empty-state" style="grid-column:1/-1"><p>No custom layouts yet</p></div>'; `<div class="empty-state" style="grid-column:1/-1"><p>${t('layout.empty_custom')}</p></div>`;
// Use template click
container.querySelectorAll('[data-use-template]').forEach(btn => { container.querySelectorAll('[data-use-template]').forEach(btn => {
btn.onclick = async () => { btn.onclick = async () => {
const layout = await API(`/layouts/${btn.dataset.useTemplate}/duplicate`, { method: 'POST', body: '{}' }); const layout = await API(`/layouts/${btn.dataset.useTemplate}/duplicate`, { method: 'POST', body: '{}' });
@ -51,23 +52,21 @@ async function renderList(container) {
}; };
}); });
// Edit layout click
container.querySelectorAll('[data-edit-layout]').forEach(btn => { container.querySelectorAll('[data-edit-layout]').forEach(btn => {
btn.onclick = () => { window.location.hash = `#/layout/${btn.dataset.editLayout}`; }; btn.onclick = () => { window.location.hash = `#/layout/${btn.dataset.editLayout}`; };
}); });
// Delete layout click
container.querySelectorAll('[data-delete-layout]').forEach(btn => { container.querySelectorAll('[data-delete-layout]').forEach(btn => {
btn.onclick = async (e) => { btn.onclick = async (e) => {
e.stopPropagation(); e.stopPropagation();
const name = btn.dataset.layoutName; const name = btn.dataset.layoutName;
if (!confirm(`Delete layout "${name}"? This cannot be undone.`)) return; if (!confirm(t('layout.confirm_delete', { name }))) return;
try { try {
await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' }); await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' });
showToast('Layout deleted'); showToast(t('layout.toast.deleted'));
renderList(container); renderList(container);
} catch (err) { } catch (err) {
showToast(err.message || 'Failed to delete layout', 'error'); showToast(err.message || t('layout.toast.delete_failed'), 'error');
} }
}; };
}); });
@ -77,6 +76,8 @@ async function renderList(container) {
} }
function renderLayoutCard(layout, isTemplate) { function renderLayoutCard(layout, isTemplate) {
const zoneCount = layout.zones?.length || 0;
const zonesText = tn('layout.zone_count', zoneCount);
return ` return `
<div class="content-item" style="cursor:pointer"> <div class="content-item" style="cursor:pointer">
<div class="content-item-preview" style="position:relative;background:var(--bg-primary)"> <div class="content-item-preview" style="position:relative;background:var(--bg-primary)">
@ -84,20 +85,20 @@ function renderLayoutCard(layout, isTemplate) {
${(layout.zones || []).map(z => ` ${(layout.zones || []).map(z => `
<div style="position:absolute;left:${z.x_percent}%;top:${z.y_percent}%;width:${z.width_percent}%;height:${z.height_percent}%; <div style="position:absolute;left:${z.x_percent}%;top:${z.y_percent}%;width:${z.width_percent}%;height:${z.height_percent}%;
background:rgba(59,130,246,0.15);border:1px solid rgba(59,130,246,0.4);display:flex;align-items:center;justify-content:center; background:rgba(59,130,246,0.15);border:1px solid rgba(59,130,246,0.4);display:flex;align-items:center;justify-content:center;
font-size:9px;color:var(--text-muted);overflow:hidden">${z.name}</div> font-size:9px;color:var(--text-muted);overflow:hidden">${esc(z.name)}</div>
`).join('')} `).join('')}
</div> </div>
</div> </div>
<div class="content-item-body"> <div class="content-item-body">
<div class="content-item-name">${layout.name}</div> <div class="content-item-name">${esc(layout.name)}</div>
<div class="content-item-size">${layout.zones?.length || 0} zone(s) ${isTemplate ? '• Template' : ''}</div> <div class="content-item-size">${zonesText}${isTemplate ? ' • ' + t('layout.template_label') : ''}</div>
</div> </div>
<div class="content-item-actions"> <div class="content-item-actions">
${isTemplate ${isTemplate
? `<button class="btn btn-primary btn-sm" data-use-template="${layout.id}">Use Template</button>` ? `<button class="btn btn-primary btn-sm" data-use-template="${layout.id}">${t('layout.use_template')}</button>`
: `<button class="btn btn-secondary btn-sm" data-edit-layout="${layout.id}">Edit</button>` : `<button class="btn btn-secondary btn-sm" data-edit-layout="${layout.id}">${t('common.edit')}</button>`
} }
<button class="btn btn-danger btn-sm" data-delete-layout="${layout.id}" data-layout-name="${layout.name}" style="margin-left:4px">Delete</button> <button class="btn btn-danger btn-sm" data-delete-layout="${layout.id}" data-layout-name="${esc(layout.name)}" style="margin-left:4px">${t('common.delete')}</button>
</div> </div>
</div> </div>
`; `;
@ -107,44 +108,51 @@ async function renderEditor(container, layoutId) {
let layout; let layout;
try { try {
layout = await API(`/layouts/${layoutId}`); layout = await API(`/layouts/${layoutId}`);
} catch { container.innerHTML = '<div class="empty-state"><h3>Layout not found</h3></div>'; return; } } catch { container.innerHTML = `<div class="empty-state"><h3>${t('layout.not_found')}</h3></div>`; return; }
container.innerHTML = ` container.innerHTML = `
<a href="#/layouts" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px"> <a href="#/layouts" class="back-link" style="display:inline-flex;align-items:center;gap:6px;color:var(--text-secondary);margin-bottom:16px;font-size:13px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Layouts ${t('layout.back')}
</a> </a>
<div class="page-header"> <div class="page-header">
<h1 id="layoutName">${layout.name}</h1> <h1 id="layoutName">${esc(layout.name)}</h1>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="addZoneBtn">Add Zone</button> <button class="btn btn-secondary btn-sm" id="addZoneBtn">${t('layout.add_zone')}</button>
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">Save</button> <button class="btn btn-primary btn-sm" id="saveLayoutBtn">${t('common.save')}</button>
</div> </div>
</div> </div>
<div style="display:flex;gap:20px"> <div style="display:flex;gap:20px">
<div style="flex:1"> <div style="flex:1">
<div id="canvasWrap" style="position:relative;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden"> <div id="canvasWrap" style="position:relative;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden">
<div id="canvas" style="position:relative;width:100%;padding-top:56.25%"> <div id="canvas" style="position:relative;width:100%;padding-top:56.25%">
<!-- Zones rendered here -->
</div> </div>
</div> </div>
</div> </div>
<div style="width:280px"> <div style="width:280px">
<h3 style="font-size:14px;margin-bottom:12px">Zones</h3> <h3 style="font-size:14px;margin-bottom:12px">${t('layout.zones')}</h3>
<div id="zoneList"></div> <div id="zoneList"></div>
<div id="zoneProperties" style="margin-top:16px;display:none"> <div id="zoneProperties" style="margin-top:16px;display:none">
<h3 style="font-size:14px;margin-bottom:12px">Properties</h3> <h3 style="font-size:14px;margin-bottom:12px">${t('layout.properties')}</h3>
<div class="form-group"><label>Name</label><input type="text" id="propName" class="input"></div> <div class="form-group"><label>${t('layout.prop.name')}</label><input type="text" id="propName" class="input"></div>
<div class="form-group"><label>X (%)</label><input type="number" id="propX" class="input" min="0" max="100" step="0.1"></div> <div class="form-group"><label>${t('layout.prop.x')}</label><input type="number" id="propX" class="input" min="0" max="100" step="0.1"></div>
<div class="form-group"><label>Y (%)</label><input type="number" id="propY" class="input" min="0" max="100" step="0.1"></div> <div class="form-group"><label>${t('layout.prop.y')}</label><input type="number" id="propY" class="input" min="0" max="100" step="0.1"></div>
<div class="form-group"><label>Width (%)</label><input type="number" id="propW" class="input" min="1" max="100" step="0.1"></div> <div class="form-group"><label>${t('layout.prop.width')}</label><input type="number" id="propW" class="input" min="1" max="100" step="0.1"></div>
<div class="form-group"><label>Height (%)</label><input type="number" id="propH" class="input" min="1" max="100" step="0.1"></div> <div class="form-group"><label>${t('layout.prop.height')}</label><input type="number" id="propH" class="input" min="1" max="100" step="0.1"></div>
<div class="form-group"><label>Type</label> <div class="form-group"><label>${t('layout.prop.type')}</label>
<select id="propType" class="input" style="background:var(--bg-input)"> <select id="propType" class="input" style="background:var(--bg-input)">
<option value="content">Content</option><option value="widget">Widget</option> <option value="content">${t('layout.type_content')}</option><option value="widget">${t('layout.type_widget')}</option>
</select> </select>
</div> </div>
<button class="btn btn-danger btn-sm" id="deleteZoneBtn" style="width:100%;justify-content:center;margin-top:8px">Delete Zone</button> <div class="form-group"><label>${t('layout.prop.fit')}</label>
<select id="propFit" class="input" style="background:var(--bg-input)">
<option value="contain">${t('layout.fit_contain')}</option>
<option value="cover">${t('layout.fit_cover')}</option>
<option value="fill">${t('layout.fit_fill')}</option>
</select>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('layout.fit_hint')}</div>
</div>
<button class="btn btn-danger btn-sm" id="deleteZoneBtn" style="width:100%;justify-content:center;margin-top:8px">${t('layout.delete_zone')}</button>
</div> </div>
</div> </div>
</div> </div>
@ -156,7 +164,6 @@ async function renderEditor(container, layoutId) {
function renderZones() { function renderZones() {
const canvas = document.getElementById('canvas'); const canvas = document.getElementById('canvas');
// Clear only zone divs
canvas.querySelectorAll('.zone-el').forEach(z => z.remove()); canvas.querySelectorAll('.zone-el').forEach(z => z.remove());
zones.forEach((z, i) => { zones.forEach((z, i) => {
@ -170,7 +177,6 @@ async function renderEditor(container, layoutId) {
user-select:none;z-index:${z.z_index || 0}`; user-select:none;z-index:${z.z_index || 0}`;
el.textContent = z.name; el.textContent = z.name;
// Drag to move
el.onmousedown = (e) => { el.onmousedown = (e) => {
if (e.target !== el) return; if (e.target !== el) return;
e.preventDefault(); e.preventDefault();
@ -200,7 +206,6 @@ async function renderEditor(container, layoutId) {
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp);
}; };
// Resize handle
const handle = document.createElement('div'); const handle = document.createElement('div');
handle.style.cssText = 'position:absolute;right:0;bottom:0;width:12px;height:12px;cursor:se-resize;background:var(--accent);border-radius:2px 0 0 0;opacity:0.7'; handle.style.cssText = 'position:absolute;right:0;bottom:0;width:12px;height:12px;cursor:se-resize;background:var(--accent);border-radius:2px 0 0 0;opacity:0.7';
handle.onmousedown = (e) => { handle.onmousedown = (e) => {
@ -228,13 +233,12 @@ async function renderEditor(container, layoutId) {
canvas.appendChild(el); canvas.appendChild(el);
}); });
// Zone list sidebar
document.getElementById('zoneList').innerHTML = zones.map((z, i) => ` document.getElementById('zoneList').innerHTML = zones.map((z, i) => `
<div style="padding:8px 10px;background:${selectedZone === i ? 'var(--bg-card-hover)' : 'var(--bg-secondary)'}; <div style="padding:8px 10px;background:${selectedZone === i ? 'var(--bg-card-hover)' : 'var(--bg-secondary)'};
border:1px solid ${selectedZone === i ? 'var(--accent)' : 'var(--border)'};border-radius:var(--radius); border:1px solid ${selectedZone === i ? 'var(--accent)' : 'var(--border)'};border-radius:var(--radius);
margin-bottom:4px;cursor:pointer;font-size:13px" data-zone-idx="${i}"> margin-bottom:4px;cursor:pointer;font-size:13px" data-zone-idx="${i}">
<div style="font-weight:500">${z.name}</div> <div style="font-weight:500">${esc(z.name)}</div>
<div style="font-size:11px;color:var(--text-muted)">${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}% ${z.zone_type}</div> <div style="font-size:11px;color:var(--text-muted)">${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}% ${esc(z.zone_type)}</div>
</div> </div>
`).join(''); `).join('');
@ -254,10 +258,10 @@ async function renderEditor(container, layoutId) {
document.getElementById('propW').value = z.width_percent; document.getElementById('propW').value = z.width_percent;
document.getElementById('propH').value = z.height_percent; document.getElementById('propH').value = z.height_percent;
document.getElementById('propType').value = z.zone_type; document.getElementById('propType').value = z.zone_type;
document.getElementById('propFit').value = z.fit_mode || 'cover';
} }
// Property input handlers ['propName', 'propX', 'propY', 'propW', 'propH', 'propType', 'propFit'].forEach(id => {
['propName', 'propX', 'propY', 'propW', 'propH', 'propType'].forEach(id => {
document.getElementById(id).oninput = () => { document.getElementById(id).oninput = () => {
if (selectedZone === null) return; if (selectedZone === null) return;
const z = zones[selectedZone]; const z = zones[selectedZone];
@ -267,12 +271,13 @@ async function renderEditor(container, layoutId) {
z.width_percent = parseFloat(document.getElementById('propW').value) || 10; z.width_percent = parseFloat(document.getElementById('propW').value) || 10;
z.height_percent = parseFloat(document.getElementById('propH').value) || 10; z.height_percent = parseFloat(document.getElementById('propH').value) || 10;
z.zone_type = document.getElementById('propType').value; z.zone_type = document.getElementById('propType').value;
z.fit_mode = document.getElementById('propFit').value;
renderZones(); renderZones();
}; };
}); });
document.getElementById('addZoneBtn').onclick = () => { document.getElementById('addZoneBtn').onclick = () => {
zones.push({ id: null, name: `Zone ${zones.length + 1}`, x_percent: 10, y_percent: 10, width_percent: 30, height_percent: 30, z_index: 0, zone_type: 'content', fit_mode: 'cover', background_color: '#000000', sort_order: zones.length }); zones.push({ id: null, name: t('layout.zone_n', { n: zones.length + 1 }), x_percent: 10, y_percent: 10, width_percent: 30, height_percent: 30, z_index: 0, zone_type: 'content', fit_mode: 'contain', background_color: '#000000', sort_order: zones.length });
selectedZone = zones.length - 1; selectedZone = zones.length - 1;
renderZones(); renderZones();
updateProperties(); updateProperties();
@ -288,16 +293,21 @@ async function renderEditor(container, layoutId) {
document.getElementById('saveLayoutBtn').onclick = async () => { document.getElementById('saveLayoutBtn').onclick = async () => {
try { try {
// Delete existing zones and recreate // Single atomic update: send the full zone set and the server replaces them
for (const z of layout.zones || []) { // exactly. The old per-zone delete-then-add loop could accumulate zones
await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' }); // (and regenerated every zone id each save). Keep each zone's id so
} // device->zone assignments survive.
for (const z of zones) { const updated = await API(`/layouts/${layoutId}`, {
await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) }); method: 'PUT',
} body: JSON.stringify({ zones }),
showToast('Layout saved', 'success'); });
layout = await API(`/layouts/${layoutId}`); if (updated && updated.error) { showToast(updated.error, 'error'); return; }
zones = layout.zones; layout = updated;
zones = layout.zones || [];
selectedZone = null;
showToast(t('layout.toast.saved'), 'success');
renderZones();
updateProperties();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }

View file

@ -1,4 +1,5 @@
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
let authConfig = null; let authConfig = null;
@ -9,49 +10,88 @@ async function loadAuthConfig() {
return authConfig; return authConfig;
} }
export async function render(container) { // #15: resolve instance/default branding for the (pre-login) login page.
const config = await loadAuthConfig(); // Public endpoint: custom-domain match -> platform default -> ScreenTinker.
const isSetup = config.needsSetup; async function loadLoginBranding() {
try {
const res = await fetch('/api/branding?domain=' + encodeURIComponent(location.hostname));
if (!res.ok) return {};
return await res.json();
} catch { return {}; }
}
container.innerHTML = ` function brandEsc(s) {
<div style="display:flex;align-items:center;justify-content:center;height:100vh;margin-left:calc(-1 * var(--sidebar-width))"> return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
<div style="width:400px;max-width:90vw"> }
<div style="text-align:center;margin-bottom:32px">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" style="margin:0 auto 12px"> // Apply document-level branding (colors, favicon, title, custom CSS) for login.
function applyLoginBrandingDoc(b) {
const root = document.documentElement;
if (b.primary_color) root.style.setProperty('--accent', b.primary_color);
if (b.bg_color) root.style.setProperty('--bg-primary', b.bg_color);
if (b.brand_name) document.title = b.brand_name;
if (b.favicon_url) {
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(l => l.setAttribute('href', b.favicon_url));
}
if (b.custom_css) {
let style = document.getElementById('wl-custom-css');
if (!style) { style = document.createElement('style'); style.id = 'wl-custom-css'; document.head.appendChild(style); }
style.textContent = b.custom_css;
}
}
export async function render(container) {
const [config, branding] = await Promise.all([loadAuthConfig(), loadLoginBranding()]);
const isSetup = config.needsSetup;
// registration_enabled may be absent on older servers — treat as enabled for back-compat
const canRegister = config.registration_enabled !== false;
applyLoginBrandingDoc(branding);
const brandName = branding.brand_name || 'ScreenTinker';
// Branded logo if set, else the default ScreenTinker glyph.
const logoHtml = branding.logo_url
? `<img src="${brandEsc(branding.logo_url)}" alt="${brandEsc(brandName)}" style="max-height:48px;max-width:200px;margin:0 auto 12px;display:block">`
: `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" style="margin:0 auto 12px">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/> <rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/> <line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/> <line x1="12" y1="17" x2="12" y2="21"/>
</svg> </svg>`;
<h1 style="font-size:24px;font-weight:700;color:var(--accent)">ScreenTinker</h1>
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
<div style="width:400px;max-width:100%">
<div style="text-align:center;margin-bottom:32px">
${logoHtml}
<h1 style="font-size:24px;font-weight:700;color:var(--accent)">${brandEsc(brandName)}</h1>
<p style="color:var(--text-secondary);font-size:13px;margin-top:4px"> <p style="color:var(--text-secondary);font-size:13px;margin-top:4px">
${isSetup ? 'Create your admin account to get started' : 'Sign in to manage your displays'} ${isSetup ? t('auth.subtitle_setup') : t('auth.subtitle_signin')}
</p> </p>
${isSetup ? '' : '<p style="color:var(--warning);font-size:12px;margin-top:8px">New accounts get a 14-day free Pro trial</p>'} ${!isSetup && canRegister ? `<p style="color:var(--warning);font-size:12px;margin-top:8px">${t('auth.trial_notice')}</p>` : ''}
</div> </div>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px"> <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px">
<!-- Local Auth Form --> <!-- Local Auth Form -->
<div id="localAuthForm"> <div id="localAuthForm">
<div class="form-group"> <div class="form-group">
<label>Email</label> <label>${t('auth.email')}</label>
<input type="email" id="loginEmail" class="input" placeholder="you@example.com" autocomplete="email"> <input type="email" id="loginEmail" class="input" placeholder="${t('auth.placeholder_email')}" autocomplete="email">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Password</label> <label>${t('auth.password')}</label>
<input type="password" id="loginPassword" class="input" placeholder="••••••••" autocomplete="current-password"> <input type="password" id="loginPassword" class="input" placeholder="${t('auth.placeholder_password')}" autocomplete="current-password">
</div> </div>
${isSetup ? ` ${isSetup ? `
<div class="form-group"> <div class="form-group">
<label>Name</label> <label>${t('auth.name')}</label>
<input type="text" id="loginName" class="input" placeholder="Your name"> <input type="text" id="loginName" class="input" placeholder="${t('auth.placeholder_name')}">
</div> </div>
` : ''} ` : ''}
<button class="btn btn-primary" id="loginBtn" style="width:100%;justify-content:center;padding:10px"> <button class="btn btn-primary" id="loginBtn" style="width:100%;justify-content:center;padding:10px">
${isSetup ? 'Create Admin Account' : 'Sign In'} ${isSetup ? t('auth.create_admin_account') : t('auth.sign_in')}
</button> </button>
${!isSetup ? ` ${!isSetup && canRegister ? `
<button class="btn btn-secondary" id="showRegisterBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px"> <button class="btn btn-secondary" id="showRegisterBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
Create Account ${t('auth.create_account')}
</button> </button>
` : ''} ` : ''}
</div> </div>
@ -59,29 +99,29 @@ export async function render(container) {
<!-- Register form (hidden by default) --> <!-- Register form (hidden by default) -->
<div id="registerForm" style="display:none"> <div id="registerForm" style="display:none">
<div class="form-group"> <div class="form-group">
<label>Name</label> <label>${t('auth.name')}</label>
<input type="text" id="regName" class="input" placeholder="Your name"> <input type="text" id="regName" class="input" placeholder="${t('auth.placeholder_name')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Email</label> <label>${t('auth.email')}</label>
<input type="email" id="regEmail" class="input" placeholder="you@example.com"> <input type="email" id="regEmail" class="input" placeholder="${t('auth.placeholder_email')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Password</label> <label>${t('auth.password')}</label>
<input type="password" id="regPassword" class="input" placeholder="At least 6 characters"> <input type="password" id="regPassword" class="input" placeholder="${t('auth.placeholder_register_password')}">
</div> </div>
<button class="btn btn-primary" id="registerBtn" style="width:100%;justify-content:center;padding:10px"> <button class="btn btn-primary" id="registerBtn" style="width:100%;justify-content:center;padding:10px">
Create Account ${t('auth.create_account')}
</button> </button>
<button class="btn btn-secondary" id="showLoginBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px"> <button class="btn btn-secondary" id="showLoginBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
Back to Sign In ${t('auth.back_to_signin')}
</button> </button>
</div> </div>
${config.googleEnabled || config.microsoftEnabled ? ` ${config.googleEnabled || config.microsoftEnabled ? `
<div style="display:flex;align-items:center;gap:12px;margin:20px 0"> <div style="display:flex;align-items:center;gap:12px;margin:20px 0">
<hr style="flex:1;border-color:var(--border)"> <hr style="flex:1;border-color:var(--border)">
<span style="color:var(--text-muted);font-size:12px">OR</span> <span style="color:var(--text-muted);font-size:12px">${t('auth.divider_or')}</span>
<hr style="flex:1;border-color:var(--border)"> <hr style="flex:1;border-color:var(--border)">
</div> </div>
` : ''} ` : ''}
@ -95,7 +135,7 @@ export async function render(container) {
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/> <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/> <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg> </svg>
Sign in with Google ${t('auth.signin_google')}
</button> </button>
</div> </div>
` : ''} ` : ''}
@ -108,25 +148,25 @@ export async function render(container) {
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/> <rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/> <rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg> </svg>
Sign in with Microsoft ${t('auth.signin_microsoft')}
</button> </button>
` : ''} ` : ''}
</div> </div>
<!-- Support Access (collapsible) --> <!-- Support Access (collapsible) -->
<details style="margin-top:16px"> <details style="margin-top:16px">
<summary style="font-size:11px;color:var(--text-muted);cursor:pointer;text-align:center">Support Access</summary> <summary style="font-size:11px;color:var(--text-muted);cursor:pointer;text-align:center">${t('auth.support_access')}</summary>
<div style="margin-top:8px"> <div style="margin-top:8px">
<input type="text" id="supportToken" class="input" placeholder="Paste support token" style="font-family:monospace;font-size:11px"> <input type="text" id="supportToken" class="input" placeholder="${t('auth.support_token_placeholder')}" style="font-family:monospace">
<button class="btn btn-secondary" id="supportLoginBtn" style="width:100%;justify-content:center;padding:8px;margin-top:6px;font-size:12px">Authenticate with Support Token</button> <button class="btn btn-secondary" id="supportLoginBtn" style="width:100%;justify-content:center;padding:8px;margin-top:6px;font-size:12px">${t('auth.support_authenticate')}</button>
</div> </div>
</details> </details>
<p id="loginError" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></p> <p id="loginError" style="color:var(--danger);font-size:12px;text-align:center;margin-top:12px;display:none"></p>
<p style="text-align:center;margin-top:16px;font-size:11px;color:var(--text-muted)"> <p style="text-align:center;margin-top:16px;font-size:11px;color:var(--text-muted)">
<a href="/legal/terms.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">Terms of Service</a> <a href="/legal/terms.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">${t('auth.terms')}</a>
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
<a href="/legal/privacy.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">Privacy Policy</a> <a href="/legal/privacy.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">${t('auth.privacy')}</a>
</p> </p>
</div> </div>
</div> </div>
@ -145,7 +185,7 @@ function setupHandlers(config, isSetup) {
// Support token login // Support token login
document.getElementById('supportLoginBtn')?.addEventListener('click', async () => { document.getElementById('supportLoginBtn')?.addEventListener('click', async () => {
const token = document.getElementById('supportToken')?.value.trim(); const token = document.getElementById('supportToken')?.value.trim();
if (!token) { showError('Paste a support token'); return; } if (!token) { showError(t('auth.error_paste_support_token')); return; }
try { try {
const res = await fetch('/api/auth/support', { const res = await fetch('/api/auth/support', {
method: 'POST', method: 'POST',
@ -155,7 +195,7 @@ function setupHandlers(config, isSetup) {
const data = await res.json(); const data = await res.json();
if (!res.ok) { showError(data.error); return; } if (!res.ok) { showError(data.error); return; }
onAuthSuccess(data); onAuthSuccess(data);
} catch (err) { showError('Support login failed'); } } catch (err) { showError(t('auth.error_support_failed')); }
}); });
// Local login/register // Local login/register
@ -182,7 +222,7 @@ function setupHandlers(config, isSetup) {
async function doLogin() { async function doLogin() {
const email = document.getElementById('loginEmail').value.trim(); const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value; const password = document.getElementById('loginPassword').value;
if (!email || !password) { showError('Email and password required'); return; } if (!email || !password) { showError(t('auth.error_email_password_required')); return; }
try { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
@ -194,7 +234,7 @@ function setupHandlers(config, isSetup) {
if (!res.ok) { showError(data.error); return; } if (!res.ok) { showError(data.error); return; }
onAuthSuccess(data); onAuthSuccess(data);
} catch (err) { } catch (err) {
showError('Login failed'); showError(t('auth.error_login_failed'));
} }
} }
@ -202,8 +242,8 @@ function setupHandlers(config, isSetup) {
const email = document.getElementById(isFirstUser ? 'loginEmail' : 'regEmail').value.trim(); const email = document.getElementById(isFirstUser ? 'loginEmail' : 'regEmail').value.trim();
const password = document.getElementById(isFirstUser ? 'loginPassword' : 'regPassword').value; const password = document.getElementById(isFirstUser ? 'loginPassword' : 'regPassword').value;
const name = document.getElementById(isFirstUser ? 'loginName' : 'regName')?.value.trim() || ''; const name = document.getElementById(isFirstUser ? 'loginName' : 'regName')?.value.trim() || '';
if (!email || !password) { showError('Email and password required'); return; } if (!email || !password) { showError(t('auth.error_email_password_required')); return; }
if (password.length < 6) { showError('Password must be at least 6 characters'); return; } if (password.length < 6) { showError(t('auth.error_password_min_6')); return; }
try { try {
const res = await fetch('/api/auth/register', { const res = await fetch('/api/auth/register', {
@ -215,7 +255,7 @@ function setupHandlers(config, isSetup) {
if (!res.ok) { showError(data.error); return; } if (!res.ok) { showError(data.error); return; }
onAuthSuccess(data); onAuthSuccess(data);
} catch (err) { } catch (err) {
showError('Registration failed'); showError(t('auth.error_registration_failed'));
} }
} }
@ -246,7 +286,7 @@ function setupHandlers(config, isSetup) {
}); });
client.requestAccessToken(); client.requestAccessToken();
} catch (err) { } catch (err) {
showError('Google sign-in failed'); showError(t('auth.error_google_failed'));
} }
}); });
} }
@ -276,7 +316,7 @@ function setupHandlers(config, isSetup) {
else showError(data.error); else showError(data.error);
} }
} catch (err) { } catch (err) {
showError('Microsoft sign-in failed'); showError(t('auth.error_microsoft_failed'));
} }
}); });
} }

View file

@ -0,0 +1,31 @@
// #12: empty state for a signed-in user who belongs to zero workspaces. Happens
// on deployments with AUTO_CREATE_ORG_ON_SIGNUP=false, where a self-service
// signup is created org-less and an admin/operator assigns them to a workspace
// afterward. Without this, such a user would be bounced into onboarding (whose
// device-pairing step needs a workspace) - a broken flow. Here they get a clear
// "ask your admin" message instead.
import { t } from '../i18n.js';
export function render(container) {
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
<div style="width:440px;max-width:100%;text-align:center">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="1.6" style="margin:0 auto 16px">
<rect x="3" y="4" width="18" height="14" rx="2"/>
<path d="M3 9h18"/>
</svg>
<h1 style="font-size:20px;font-weight:700;margin-bottom:8px">${t('noworkspace.title')}</h1>
<p style="color:var(--text-secondary);font-size:14px;line-height:1.6;margin-bottom:24px">${t('noworkspace.body')}</p>
<button class="btn btn-secondary" id="noWsSignOut" style="padding:8px 16px">${t('noworkspace.sign_out')}</button>
</div>
</div>
`;
container.querySelector('#noWsSignOut').addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.hash = '#/login';
window.location.reload();
});
}
export function cleanup() {}

View file

@ -1,96 +1,101 @@
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
const STEPS = [ // Steps are computed lazily so translated strings refresh on language change.
function getSteps() {
return [
{ {
title: 'Welcome to ScreenTinker!', title: t('onboarding.step.welcome.title'),
icon: '&#128075;', icon: '&#128075;',
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:16px">Let's get you set up in under 5 minutes.</p> content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.welcome.intro')}</p>
<p style="color:var(--text-muted);font-size:14px">This wizard will guide you through:</p> <p style="color:var(--text-muted);font-size:14px">${t('onboarding.step.welcome.guide_through')}</p>
<ul style="color:var(--text-muted);font-size:14px;padding-left:20px;margin-top:8px;line-height:2"> <ul style="color:var(--text-muted);font-size:14px;padding-left:20px;margin-top:8px;line-height:2">
<li>Downloading the player app</li> <li>${t('onboarding.step.welcome.bullet_download')}</li>
<li>Pairing your first display</li> <li>${t('onboarding.step.welcome.bullet_pair')}</li>
<li>Uploading and assigning content</li> <li>${t('onboarding.step.welcome.bullet_upload')}</li>
</ul>`, </ul>`,
action: null action: null
}, },
{ {
title: 'Step 1: Get the Player App', title: t('onboarding.step.player.title'),
icon: '&#128229;', icon: '&#128229;',
content: `<p style="color:var(--text-secondary);margin-bottom:16px">Install the player on your display device.</p> content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.player.intro')}</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<a href="/download/apk" style="background:var(--bg-input);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center;text-decoration:none;color:var(--text-primary)"> <a href="/download/apk" style="background:var(--bg-input);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center;text-decoration:none;color:var(--text-primary)">
<div style="font-size:32px;margin-bottom:8px">&#129302;</div> <div style="font-size:32px;margin-bottom:8px">&#129302;</div>
<div style="font-weight:600;font-size:14px">Android APK</div> <div style="font-weight:600;font-size:14px">${t('onboarding.step.player.android_label')}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">TV boxes, tablets, Fire TV</div> <div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('onboarding.step.player.android_desc')}</div>
</a> </a>
<a href="/player" target="_blank" style="background:var(--bg-input);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center;text-decoration:none;color:var(--text-primary)"> <a href="/player" target="_blank" style="background:var(--bg-input);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center;text-decoration:none;color:var(--text-primary)">
<div style="font-size:32px;margin-bottom:8px">&#127760;</div> <div style="font-size:32px;margin-bottom:8px">&#127760;</div>
<div style="font-weight:600;font-size:14px">Web Player</div> <div style="font-weight:600;font-size:14px">${t('onboarding.step.player.web_label')}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Any browser, Pi, ChromeOS</div> <div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('onboarding.step.player.web_desc')}</div>
</a> </a>
</div> </div>
<p style="color:var(--text-muted);font-size:12px;margin-top:12px">Open the app on your display and enter this server URL:</p> <p style="color:var(--text-muted);font-size:12px;margin-top:12px">${t('onboarding.step.player.url_hint')}</p>
<code style="display:block;background:var(--bg-input);padding:10px;border-radius:6px;margin-top:6px;font-size:14px;user-select:all">${window.location.origin}</code>`, <code style="display:block;background:var(--bg-input);padding:10px;border-radius:6px;margin-top:6px;font-size:14px;user-select:all">${window.location.origin}</code>`,
action: null action: null
}, },
{ {
title: 'Step 2: Pair Your Display', title: t('onboarding.step.pair.title'),
icon: '&#128279;', icon: '&#128279;',
content: `<p style="color:var(--text-secondary);margin-bottom:16px">Enter the 6-digit code shown on your display.</p> content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.pair.intro')}</p>
<div style="text-align:center;margin:20px 0"> <div style="text-align:center;margin:20px 0">
<input type="text" id="onboardPairingCode" maxlength="6" pattern="[0-9]{6}" placeholder="000000" <input type="text" id="onboardPairingCode" maxlength="6" pattern="[0-9]{6}" placeholder="000000"
style="width:240px;padding:16px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px; style="max-width:240px;width:100%;padding:16px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;
color:var(--text-primary);font-size:32px;font-weight:700;text-align:center;letter-spacing:8px;font-family:monospace"> color:var(--text-primary);font-size:32px;font-weight:700;text-align:center;letter-spacing:8px;font-family:monospace">
</div> </div>
<div style="text-align:center"> <div style="text-align:center">
<input type="text" id="onboardDeviceName" placeholder="Display name (e.g., Lobby TV)" <input type="text" id="onboardDeviceName" placeholder="${t('onboarding.step.pair.name_placeholder')}"
style="width:240px;padding:10px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;text-align:center"> style="max-width:240px;width:100%;padding:10px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;text-align:center">
</div> </div>
<p id="onboardPairStatus" style="color:var(--text-muted);font-size:13px;text-align:center;margin-top:12px"></p>`, <p id="onboardPairStatus" style="color:var(--text-muted);font-size:13px;text-align:center;margin-top:12px"></p>`,
action: 'pair' action: 'pair'
}, },
{ {
title: 'Step 3: Upload Content', title: t('onboarding.step.upload.title'),
icon: '&#128228;', icon: '&#128228;',
content: `<p style="color:var(--text-secondary);margin-bottom:16px">Upload a video or image to display.</p> content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.upload.intro')}</p>
<div style="border:2px dashed var(--border);border-radius:12px;padding:32px;text-align:center;cursor:pointer" id="onboardUploadArea"> <div style="border:2px dashed var(--border);border-radius:12px;padding:32px;text-align:center;cursor:pointer" id="onboardUploadArea">
<div style="font-size:32px;margin-bottom:8px">&#128193;</div> <div style="font-size:32px;margin-bottom:8px">&#128193;</div>
<p style="color:var(--text-secondary)">Click to select a file</p> <p style="color:var(--text-secondary)">${t('onboarding.step.upload.click_to_select')}</p>
<p style="color:var(--text-muted);font-size:12px;margin-top:4px">MP4, WebM, JPEG, PNG, GIF</p> <p style="color:var(--text-muted);font-size:12px;margin-top:4px">${t('onboarding.step.upload.formats')}</p>
<input type="file" id="onboardFileInput" style="display:none" accept="video/*,image/*"> <input type="file" id="onboardFileInput" style="display:none" accept="video/*,image/*">
</div> </div>
<div id="onboardUploadProgress" style="display:none;margin-top:12px"> <div id="onboardUploadProgress" style="display:none;margin-top:12px">
<div style="height:4px;background:var(--bg-primary);border-radius:2px;overflow:hidden"> <div style="height:4px;background:var(--bg-primary);border-radius:2px;overflow:hidden">
<div id="onboardProgressBar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div> <div id="onboardProgressBar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
</div> </div>
<p id="onboardUploadText" style="font-size:12px;color:var(--text-muted);margin-top:6px">Uploading...</p> <p id="onboardUploadText" style="font-size:12px;color:var(--text-muted);margin-top:6px">${t('onboarding.step.upload.uploading')}</p>
</div>`, </div>`,
action: 'upload' action: 'upload'
}, },
{ {
title: "You're All Set!", title: t('onboarding.step.done.title'),
icon: '&#127881;', icon: '&#127881;',
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:20px">Your display is paired and content is playing!</p> content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:20px">${t('onboarding.step.done.intro')}</p>
<div style="background:var(--bg-input);border-radius:8px;padding:16px;margin-bottom:16px"> <div style="background:var(--bg-input);border-radius:8px;padding:16px;margin-bottom:16px">
<p style="font-size:14px;color:var(--text-primary);font-weight:600;margin-bottom:8px">What's next?</p> <p style="font-size:14px;color:var(--text-primary);font-weight:600;margin-bottom:8px">${t('onboarding.step.done.whats_next')}</p>
<ul style="color:var(--text-muted);font-size:13px;padding-left:20px;line-height:2"> <ul style="color:var(--text-muted);font-size:13px;padding-left:20px;line-height:2">
<li>Add more content in the <strong>Content Library</strong></li> <li>${t('onboarding.step.done.next_content')}</li>
<li>Create multi-zone layouts in <strong>Layouts</strong></li> <li>${t('onboarding.step.done.next_layouts')}</li>
<li>Set up a schedule in the <strong>Schedule</strong> calendar</li> <li>${t('onboarding.step.done.next_schedule')}</li>
<li>Add live widgets (clock, weather, ticker) in <strong>Widgets</strong></li> <li>${t('onboarding.step.done.next_widgets')}</li>
<li>Create interactive screens in <strong>Kiosk</strong></li> <li>${t('onboarding.step.done.next_kiosk')}</li>
<li>Design custom content in the <strong>Designer</strong></li> <li>${t('onboarding.step.done.next_designer')}</li>
</ul> </ul>
</div>`, </div>`,
action: null action: null
} }
]; ];
}
export function render(container) { export function render(container) {
let currentStep = 0; let currentStep = 0;
let pairedDeviceId = null; let pairedDeviceId = null;
function renderStep() { function renderStep() {
const STEPS = getSteps();
const step = STEPS[currentStep]; const step = STEPS[currentStep];
const isFirst = currentStep === 0; const isFirst = currentStep === 0;
const isLast = currentStep === STEPS.length - 1; const isLast = currentStep === STEPS.length - 1;
@ -113,17 +118,16 @@ export function render(container) {
</div> </div>
<div style="display:flex;justify-content:space-between"> <div style="display:flex;justify-content:space-between">
${isFirst ? '<div></div>' : `<button class="btn btn-secondary" id="prevBtn">Back</button>`} ${isFirst ? '<div></div>' : `<button class="btn btn-secondary" id="prevBtn">${t('onboarding.back')}</button>`}
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
${!isLast ? `<button class="btn btn-secondary" id="skipBtn" style="color:var(--text-muted)">Skip Wizard</button>` : ''} ${!isLast ? `<button class="btn btn-secondary" id="skipBtn" style="color:var(--text-muted)">${t('onboarding.skip')}</button>` : ''}
<button class="btn btn-primary" id="nextBtn">${isLast ? 'Go to Dashboard' : step.action ? (step.action === 'pair' ? 'Pair Display' : 'Next') : 'Next'}</button> <button class="btn btn-primary" id="nextBtn">${isLast ? t('onboarding.go_to_dashboard') : step.action === 'pair' ? t('onboarding.pair_display') : t('onboarding.next')}</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
`; `;
// Bind buttons
document.getElementById('prevBtn')?.addEventListener('click', () => { currentStep--; renderStep(); }); document.getElementById('prevBtn')?.addEventListener('click', () => { currentStep--; renderStep(); });
document.getElementById('skipBtn')?.addEventListener('click', () => { document.getElementById('skipBtn')?.addEventListener('click', () => {
localStorage.setItem('rd_onboarded', 'true'); localStorage.setItem('rd_onboarded', 'true');
@ -132,7 +136,6 @@ export function render(container) {
}); });
document.getElementById('nextBtn')?.addEventListener('click', handleNext); document.getElementById('nextBtn')?.addEventListener('click', handleNext);
// Step-specific setup
if (step.action === 'upload') { if (step.action === 'upload') {
const area = document.getElementById('onboardUploadArea'); const area = document.getElementById('onboardUploadArea');
const input = document.getElementById('onboardFileInput'); const input = document.getElementById('onboardFileInput');
@ -142,6 +145,7 @@ export function render(container) {
} }
async function handleNext() { async function handleNext() {
const STEPS = getSteps();
const step = STEPS[currentStep]; const step = STEPS[currentStep];
if (step.action === 'pair') { if (step.action === 'pair') {
@ -150,12 +154,12 @@ export function render(container) {
const status = document.getElementById('onboardPairStatus'); const status = document.getElementById('onboardPairStatus');
if (!code || code.length !== 6) { if (!code || code.length !== 6) {
if (status) status.textContent = 'Enter a valid 6-digit code'; if (status) status.textContent = t('onboarding.toast.invalid_code');
return; return;
} }
try { try {
if (status) status.textContent = 'Pairing...'; if (status) status.textContent = t('onboarding.toast.pairing');
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const res = await fetch('/api/provision/pair', { const res = await fetch('/api/provision/pair', {
method: 'POST', method: 'POST',
@ -163,13 +167,13 @@ export function render(container) {
body: JSON.stringify({ pairing_code: code, name: name || undefined }) body: JSON.stringify({ pairing_code: code, name: name || undefined })
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (status) status.textContent = data.error || 'Pairing failed'; return; } if (!res.ok) { if (status) status.textContent = data.error || t('onboarding.toast.pair_failed'); return; }
pairedDeviceId = data.id; pairedDeviceId = data.id;
showToast('Display paired!', 'success'); showToast(t('onboarding.toast.paired'), 'success');
currentStep++; currentStep++;
renderStep(); renderStep();
} catch (err) { } catch (err) {
if (status) status.textContent = 'Pairing failed: ' + err.message; if (status) status.textContent = t('onboarding.toast.pair_failed_with_error', { error: err.message });
} }
return; return;
} }
@ -208,9 +212,8 @@ export function render(container) {
xhr.onload = async () => { xhr.onload = async () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
const content = JSON.parse(xhr.responseText); const content = JSON.parse(xhr.responseText);
if (text) text.textContent = 'Uploaded! Assigning to display...'; if (text) text.textContent = t('onboarding.toast.uploaded_assigning');
// Auto-assign to paired device
if (pairedDeviceId) { if (pairedDeviceId) {
try { try {
await fetch(`/api/assignments/device/${pairedDeviceId}`, { await fetch(`/api/assignments/device/${pairedDeviceId}`, {
@ -221,17 +224,17 @@ export function render(container) {
} catch {} } catch {}
} }
showToast('Content uploaded and assigned!', 'success'); showToast(t('onboarding.toast.content_assigned'), 'success');
currentStep++; currentStep++;
renderStep(); renderStep();
} else { } else {
if (text) text.textContent = 'Upload failed'; if (text) text.textContent = t('onboarding.toast.upload_failed');
} }
}; };
xhr.onerror = () => { if (text) text.textContent = 'Upload failed'; }; xhr.onerror = () => { if (text) text.textContent = t('onboarding.toast.upload_failed'); };
xhr.send(formData); xhr.send(formData);
} catch (err) { } catch (err) {
if (text) text.textContent = 'Error: ' + err.message; if (text) text.textContent = t('onboarding.toast.error_with_error', { error: err.message });
} }
} }

View file

@ -1,6 +1,7 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
import { t, tn } from '../i18n.js';
function formatDate(ts) { function formatDate(ts) {
if (!ts) return '--'; if (!ts) return '--';
@ -13,6 +14,39 @@ function getTypeIcon(item) {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>'; return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
} }
// #74/#75 per-item schedule editor helpers. Client validation MIRRORS the server
// (server/routes/playlists.js validateBlocks): same time/date regexes, non-empty days.
const SCHED_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
const SCHED_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function daysSummary(days) {
const labels = t('itemsched.dow_short').split(',');
const s = [...days].sort((a, b) => a - b);
if (s.length === 7) return t('itemsched.every_day');
if (s.length === 5 && [1, 2, 3, 4, 5].every(d => s.includes(d))) return t('itemsched.mon_fri');
if (s.length === 2 && s.includes(0) && s.includes(6)) return t('itemsched.sat_sun');
return s.map(d => labels[d]).join(' ');
}
function blockSummary(b) {
let s = `${daysSummary(b.days)} ${b.start}-${b.end}`;
if (b.start_date || b.end_date) s += ` · ${b.start_date || '…'}${b.end_date || '…'}`;
return s;
}
function scheduleSummary(schedules) {
if (!schedules || !schedules.length) return '';
return schedules.length === 1 ? blockSummary(schedules[0]) : `${blockSummary(schedules[0])} +${schedules.length - 1}`;
}
function validateScheduleBlocks(blocks) {
for (const b of blocks) {
if (!b.days || !b.days.length) return t('itemsched.err.days');
if (!SCHED_TIME_RE.test(b.start)) return t('itemsched.err.start');
if (!(SCHED_TIME_RE.test(b.end) || b.end === '24:00')) return t('itemsched.err.end');
if (b.start_date && !SCHED_DATE_RE.test(b.start_date)) return t('itemsched.err.start_date');
if (b.end_date && !SCHED_DATE_RE.test(b.end_date)) return t('itemsched.err.end_date');
}
return null;
}
let currentPlaylistId = null; let currentPlaylistId = null;
export function render(container) { export function render(container) {
@ -31,27 +65,25 @@ export function cleanup() {
currentPlaylistId = null; currentPlaylistId = null;
} }
// ==================== LIST VIEW ====================
let showAutoGenerated = true; let showAutoGenerated = true;
async function renderList(container) { async function renderList(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>Playlists</h1> <h1>${t('playlist.title')}</h1>
<div class="subtitle">Create and manage content playlists</div> <div class="subtitle">${t('playlist.subtitle')}</div>
</div> </div>
<div style="display:flex;gap:8px;align-items:center"> <div style="display:flex;gap:8px;align-items:center">
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-secondary);cursor:pointer"> <label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-secondary);cursor:pointer">
<input type="checkbox" id="showAutoToggle" ${showAutoGenerated ? 'checked' : ''}> <input type="checkbox" id="showAutoToggle" ${showAutoGenerated ? 'checked' : ''}>
Show auto-generated ${t('playlist.show_auto_generated')}
</label> </label>
<button class="btn btn-primary" id="createPlaylistBtn">+ New Playlist</button> <button class="btn btn-primary" id="createPlaylistBtn">${t('playlist.new_playlist_btn')}</button>
</div> </div>
</div> </div>
<div id="playlistGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px"> <div id="playlistGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px">
<div style="color:var(--text-muted);padding:40px;text-align:center">Loading...</div> <div style="color:var(--text-muted);padding:40px;text-align:center">${t('common.loading')}</div>
</div> </div>
`; `;
@ -76,8 +108,8 @@ async function loadPlaylists() {
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/> <line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/> <line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
</svg> </svg>
<h3 style="margin-bottom:8px;color:var(--text-primary)">No playlists yet</h3> <h3 style="margin-bottom:8px;color:var(--text-primary)">${t('playlist.empty_title')}</h3>
<p>Create your first playlist to organize content for your displays.</p> <p>${t('playlist.empty_desc')}</p>
</div> </div>
`; `;
return; return;
@ -87,7 +119,7 @@ async function loadPlaylists() {
if (!filtered.length) { if (!filtered.length) {
grid.innerHTML = ` grid.innerHTML = `
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)"> <div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)">
${playlists.length ? 'All playlists are auto-generated. Toggle "Show auto-generated" to see them.' : ''} ${playlists.length ? t('playlist.all_auto_generated') : ''}
</div> </div>
`; `;
return; return;
@ -98,19 +130,20 @@ async function loadPlaylists() {
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:12px"> <div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
<div style="font-size:16px;font-weight:600;color:var(--text-primary)">${esc(p.name)}</div> <div style="font-size:16px;font-weight:600;color:var(--text-primary)">${esc(p.name)}</div>
${p.is_auto_generated ? '<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:var(--bg-input);color:var(--text-muted)">auto</span>' : ''} ${p.is_auto_generated ? `<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:var(--bg-input);color:var(--text-muted)">${t('playlist.tag_auto')}</span>` : ''}
${p.status === 'draft' ? `<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:#78350f;color:#fbbf24">${t('playlist.tag_draft')}</span>` : ''}
</div> </div>
<div style="font-size:12px;color:var(--text-muted);white-space:nowrap;margin-left:12px">${p.item_count} item${p.item_count !== 1 ? 's' : ''}</div> <div style="font-size:12px;color:var(--text-muted);white-space:nowrap;margin-left:12px">${tn('playlist.item_count', p.item_count)}</div>
</div> </div>
${p.description ? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;line-height:1.4">${esc(p.description)}</div>` : ''} ${p.description ? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;line-height:1.4">${esc(p.description)}</div>` : ''}
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted)"> <div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted)">
<span>Created ${formatDate(p.created_at)}</span> <span>${t('playlist.created_at', { date: formatDate(p.created_at) })}</span>
${p.display_count ? `<span>${p.display_count} display${p.display_count !== 1 ? 's' : ''}</span>` : ''} ${p.display_count ? `<span>${tn('playlist.display_count', p.display_count)}</span>` : ''}
</div> </div>
</a> </a>
`).join(''); `).join('');
} catch (err) { } catch (err) {
grid.innerHTML = `<div style="grid-column:1/-1;color:var(--text-muted);padding:40px;text-align:center">Failed to load playlists: ${esc(err.message)}</div>`; grid.innerHTML = `<div style="grid-column:1/-1;color:var(--text-muted);padding:40px;text-align:center">${t('playlist.load_failed', { error: esc(err.message) })}</div>`;
} }
} }
@ -119,12 +152,12 @@ function showCreateModal() {
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
modal.innerHTML = ` modal.innerHTML = `
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:400px;max-width:90vw"> <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:400px;max-width:90vw">
<h3 style="margin-bottom:16px;color:var(--text-primary)">New Playlist</h3> <h3 style="margin-bottom:16px;color:var(--text-primary)">${t('playlist.new_playlist')}</h3>
<input type="text" id="newPlaylistName" class="input" placeholder="Playlist name" style="width:100%;margin-bottom:12px" autofocus> <input type="text" id="newPlaylistName" class="input" placeholder="${t('playlist.name_placeholder')}" style="width:100%;margin-bottom:12px" autofocus>
<textarea id="newPlaylistDesc" class="input" placeholder="Description (optional)" style="width:100%;height:60px;resize:vertical;margin-bottom:16px"></textarea> <textarea id="newPlaylistDesc" class="input" placeholder="${t('playlist.desc_placeholder')}" style="width:100%;height:60px;resize:vertical;margin-bottom:16px"></textarea>
<div style="display:flex;gap:8px;justify-content:flex-end"> <div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-secondary" id="cancelCreateBtn">Cancel</button> <button class="btn btn-secondary" id="cancelCreateBtn">${t('common.cancel')}</button>
<button class="btn btn-primary" id="confirmCreateBtn">Create</button> <button class="btn btn-primary" id="confirmCreateBtn">${t('playlist.create_btn')}</button>
</div> </div>
</div> </div>
`; `;
@ -143,7 +176,7 @@ function showCreateModal() {
try { try {
const pl = await api.createPlaylist(name, desc); const pl = await api.createPlaylist(name, desc);
modal.remove(); modal.remove();
showToast('Playlist created'); showToast(t('playlist.toast.created'));
window.location.hash = `#/playlists/${pl.id}`; window.location.hash = `#/playlists/${pl.id}`;
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -154,11 +187,9 @@ function showCreateModal() {
nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doCreate(); }); nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doCreate(); });
} }
// ==================== DETAIL VIEW ====================
async function renderDetail(container, playlistId) { async function renderDetail(container, playlistId) {
container.innerHTML = ` container.innerHTML = `
<div style="color:var(--text-muted);padding:40px;text-align:center">Loading...</div> <div style="color:var(--text-muted);padding:40px;text-align:center">${t('common.loading')}</div>
`; `;
try { try {
@ -167,27 +198,46 @@ async function renderDetail(container, playlistId) {
} catch (err) { } catch (err) {
container.innerHTML = ` container.innerHTML = `
<div style="padding:40px;text-align:center;color:var(--text-muted)"> <div style="padding:40px;text-align:center;color:var(--text-muted)">
<p>Failed to load playlist: ${esc(err.message)}</p> <p>${t('playlist.load_failed', { error: esc(err.message) })}</p>
<a href="#/playlists" class="btn btn-secondary" style="margin-top:16px">Back to Playlists</a> <a href="#/playlists" class="btn btn-secondary" style="margin-top:16px">${t('playlist.back_to_playlists')}</a>
</div> </div>
`; `;
} }
} }
function renderDetailContent(container, playlist) { function renderDetailContent(container, playlist) {
const isDraft = playlist.status === 'draft';
const hasPublished = !!playlist.published_snapshot;
container.innerHTML = ` container.innerHTML = `
${isDraft ? `
<div id="draftBanner" style="background:#78350f;border:1px solid #92400e;border-radius:var(--radius-lg);padding:14px 20px;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px">
<div style="display:flex;align-items:center;gap:10px;color:#fbbf24">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<div>
<div style="font-weight:600;font-size:14px">${t('playlist.draft.banner_title')}</div>
<div style="font-size:12px;color:#fcd34d;opacity:0.85">${hasPublished ? t('playlist.draft.devices_showing_published') : t('playlist.draft.never_published')}</div>
</div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0">
${hasPublished ? `<button class="btn btn-secondary btn-sm" id="discardDraftBtn" style="color:#fbbf24;border-color:#92400e">${t('playlist.draft.discard_changes')}</button>` : ''}
<button class="btn btn-sm" id="publishBtn" style="background:#f59e0b;color:#000;font-weight:600;border:none">${t('playlist.draft.publish')}</button>
</div>
</div>
` : ''}
<div class="page-header"> <div class="page-header">
<div style="display:flex;align-items:center;gap:12px"> <div style="display:flex;align-items:center;gap:12px">
<a href="#/playlists" style="color:var(--text-muted);text-decoration:none;font-size:20px" title="Back">&larr;</a> <a href="#/playlists" style="color:var(--text-muted);text-decoration:none;font-size:20px" title="${t('playlist.back')}">&larr;</a>
<div> <div>
<h1 id="playlistTitle" style="cursor:pointer" title="Click to rename">${esc(playlist.name)}</h1> <h1 id="playlistTitle" style="cursor:pointer" title="${t('playlist.click_to_rename')}">${esc(playlist.name)}</h1>
<div class="subtitle" id="playlistDesc" style="cursor:pointer" title="Click to edit description">${playlist.description ? esc(playlist.description) : '<span style="opacity:0.5">Add a description...</span>'}</div> <div class="subtitle" id="playlistDesc" style="cursor:pointer" title="${t('playlist.click_to_edit_desc')}">${playlist.description ? esc(playlist.description) : `<span style="opacity:0.5">${t('playlist.add_desc_placeholder')}</span>`}</div>
${playlist.display_count ? `<div style="font-size:12px;color:var(--text-muted);margin-top:4px">Assigned to ${playlist.display_count} display${playlist.display_count !== 1 ? 's' : ''}</div>` : ''} ${playlist.display_count ? `<div style="font-size:12px;color:var(--text-muted);margin-top:4px">${tn('playlist.assigned_to', playlist.display_count)}</div>` : ''}
</div> </div>
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-primary" id="addItemBtn">+ Add Content</button> <button class="btn btn-primary" id="addItemBtn">${t('playlist.add_content')}</button>
<button class="btn btn-secondary" id="deletePlaylistBtn" style="color:var(--danger)">Delete Playlist</button> <button class="btn btn-secondary" id="deletePlaylistBtn" style="color:var(--danger)">${t('playlist.delete_playlist')}</button>
</div> </div>
</div> </div>
@ -197,19 +247,46 @@ function renderDetailContent(container, playlist) {
renderItems(playlist.items || []); renderItems(playlist.items || []);
// Inline rename const publishBtn = document.getElementById('publishBtn');
if (publishBtn) {
publishBtn.addEventListener('click', async () => {
try {
publishBtn.disabled = true;
publishBtn.textContent = t('playlist.draft.publishing');
const updated = await api.publishPlaylist(playlist.id);
showToast(t('playlist.toast.published'));
renderDetailContent(container, updated);
} catch (err) {
publishBtn.disabled = false;
publishBtn.textContent = t('playlist.draft.publish');
showToast(err.message, 'error');
}
});
}
const discardBtn = document.getElementById('discardDraftBtn');
if (discardBtn) {
discardBtn.addEventListener('click', async () => {
if (!confirm(t('playlist.confirm_discard_draft'))) return;
try {
const updated = await api.discardPlaylistDraft(playlist.id);
showToast(t('playlist.toast.draft_discarded'));
renderDetailContent(container, updated);
} catch (err) {
showToast(err.message, 'error');
}
});
}
document.getElementById('playlistTitle').addEventListener('click', () => inlineEdit(playlist, 'name')); document.getElementById('playlistTitle').addEventListener('click', () => inlineEdit(playlist, 'name'));
document.getElementById('playlistDesc').addEventListener('click', () => inlineEdit(playlist, 'description')); document.getElementById('playlistDesc').addEventListener('click', () => inlineEdit(playlist, 'description'));
// Add content
document.getElementById('addItemBtn').addEventListener('click', () => showAddItemModal(playlist.id)); document.getElementById('addItemBtn').addEventListener('click', () => showAddItemModal(playlist.id));
// Delete playlist
document.getElementById('deletePlaylistBtn').addEventListener('click', async () => { document.getElementById('deletePlaylistBtn').addEventListener('click', async () => {
if (!confirm(`Delete "${playlist.name}"? This cannot be undone.`)) return; if (!confirm(t('playlist.confirm_delete', { name: playlist.name }))) return;
try { try {
await api.deletePlaylist(playlist.id); await api.deletePlaylist(playlist.id);
showToast('Playlist deleted'); showToast(t('playlist.toast.deleted'));
window.location.hash = '#/playlists'; window.location.hash = '#/playlists';
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -217,6 +294,16 @@ function renderDetailContent(container, playlist) {
}); });
} }
async function refreshAfterMutation() {
if (!currentPlaylistId) return;
const mainContainer = document.getElementById('draftBanner')?.parentElement || document.querySelector('.page-header')?.parentElement;
if (!mainContainer) return;
try {
const playlist = await api.getPlaylist(currentPlaylistId);
renderDetailContent(mainContainer, playlist);
} catch (e) { /* silent */ }
}
function renderItems(items) { function renderItems(items) {
const itemsEl = document.getElementById('playlistItems'); const itemsEl = document.getElementById('playlistItems');
if (!itemsEl) return; if (!itemsEl) return;
@ -224,38 +311,51 @@ function renderItems(items) {
if (!items.length) { if (!items.length) {
itemsEl.innerHTML = ` itemsEl.innerHTML = `
<div style="text-align:center;padding:40px;color:var(--text-muted);border:2px dashed var(--border);border-radius:var(--radius-lg)"> <div style="text-align:center;padding:40px;color:var(--text-muted);border:2px dashed var(--border);border-radius:var(--radius-lg)">
<p style="margin-bottom:8px">This playlist is empty</p> <p style="margin-bottom:8px">${t('playlist.items_empty')}</p>
<p style="font-size:13px">Click "Add Content" to add items.</p> <p style="font-size:13px">${t('playlist.items_empty_hint')}</p>
</div> </div>
`; `;
return; return;
} }
itemsEl.innerHTML = items.map((item, i) => ` itemsEl.innerHTML = items.map((item, i) => `
<div class="playlist-item" data-item-id="${item.id}" draggable="true" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;display:flex;align-items:center;gap:12px;cursor:grab;transition:border-color 0.15s"> <div class="playlist-item" data-item-id="${item.id}" data-index="${i}" draggable="true" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;display:flex;align-items:center;gap:12px;cursor:grab;transition:border-color 0.15s">
<div style="color:var(--text-muted);font-size:12px;min-width:24px;text-align:center;user-select:none">${i + 1}</div> <div style="color:var(--text-muted);font-size:12px;min-width:24px;text-align:center;user-select:none">${i + 1}</div>
<div style="width:48px;height:36px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center"> <div style="width:48px;height:36px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center">
${item.thumbnail_path ${item.thumbnail_path
? `<img src="/uploads/thumbnails/${esc(item.thumbnail_path.split('/').pop())}" style="width:100%;height:100%;object-fit:cover">` ? `<img src="/api/content/${esc(item.content_id)}/thumbnail" style="width:100%;height:100%;object-fit:cover">`
: `<div style="color:var(--text-muted);opacity:0.5">${getTypeIcon(item)}</div>` : `<div style="color:var(--text-muted);opacity:0.5">${getTypeIcon(item)}</div>`
} }
</div> </div>
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0">
<div style="font-size:14px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(item.filename || item.widget_name || 'Unknown')}</div> <div style="font-size:14px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(item.filename || item.widget_name || t('common.unknown'))}</div>
<div style="font-size:12px;color:var(--text-muted)">${item.widget_id ? 'Widget' : (item.mime_type || 'Unknown type')}</div> <div style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:8px;min-width:0">
<span style="white-space:nowrap">${item.widget_id ? t('playlist.item_widget') : esc(item.mime_type || t('playlist.unknown_type'))}</span>
${item.schedules && item.schedules.length ? `<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:#0c2a3f;color:#7dd3fc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${esc(scheduleSummary(item.schedules))}">🕐 ${esc(scheduleSummary(item.schedules))}</span>` : ''}
</div>
</div> </div>
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0"> <div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
<label style="font-size:12px;color:var(--text-muted)">Duration</label> <label style="font-size:12px;color:var(--text-muted)">${t('playlist.duration')}</label>
<input type="number" class="input item-duration" data-item-id="${item.id}" value="${item.duration_sec}" min="1" style="width:60px;padding:4px 8px;font-size:13px;text-align:center"> <input type="number" class="input item-duration" data-item-id="${item.id}" value="${item.duration_sec}" min="1" style="width:60px;padding:4px 8px;font-size:13px;text-align:center">
<span style="font-size:12px;color:var(--text-muted)">sec</span> <span style="font-size:12px;color:var(--text-muted)">${t('playlist.sec')}</span>
</div> </div>
<button class="btn-icon item-remove" data-item-id="${item.id}" title="Remove" style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px"> <div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<button class="btn-icon item-schedule" data-item-id="${item.id}" title="${t('itemsched.title')}" aria-label="${t('itemsched.title')}" style="color:${item.schedules && item.schedules.length ? '#38bdf8' : 'var(--text-muted)'};background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>
</button>
<button class="btn-icon item-move" data-item-id="${item.id}" data-dir="up" title="${t('playlist.move_up')}" aria-label="${t('playlist.move_up')}" ${i === 0 ? 'disabled' : ''} style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px;${i === 0 ? 'opacity:0.3;cursor:not-allowed' : ''}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>
</button>
<button class="btn-icon item-move" data-item-id="${item.id}" data-dir="down" title="${t('playlist.move_down')}" aria-label="${t('playlist.move_down')}" ${i === items.length - 1 ? 'disabled' : ''} style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px;${i === items.length - 1 ? 'opacity:0.3;cursor:not-allowed' : ''}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<button class="btn-icon item-remove" data-item-id="${item.id}" title="${t('common.delete')}" aria-label="${t('playlist.remove_item')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
</div>
`).join(''); `).join('');
// Duration change handlers
itemsEl.querySelectorAll('.item-duration').forEach(input => { itemsEl.querySelectorAll('.item-duration').forEach(input => {
input.addEventListener('change', async (e) => { input.addEventListener('change', async (e) => {
const itemId = e.target.dataset.itemId; const itemId = e.target.dataset.itemId;
@ -263,13 +363,13 @@ function renderItems(items) {
if (!val || val < 1) { e.target.value = 10; return; } if (!val || val < 1) { e.target.value = 10; return; }
try { try {
await api.updatePlaylistItem(currentPlaylistId, itemId, { duration_sec: val }); await api.updatePlaylistItem(currentPlaylistId, itemId, { duration_sec: val });
refreshAfterMutation();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
}); });
}); });
// Remove handlers
itemsEl.querySelectorAll('.item-remove').forEach(btn => { itemsEl.querySelectorAll('.item-remove').forEach(btn => {
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
const itemId = e.currentTarget.dataset.itemId; const itemId = e.currentTarget.dataset.itemId;
@ -277,14 +377,43 @@ function renderItems(items) {
await api.deletePlaylistItem(currentPlaylistId, itemId); await api.deletePlaylistItem(currentPlaylistId, itemId);
const playlist = await api.getPlaylist(currentPlaylistId); const playlist = await api.getPlaylist(currentPlaylistId);
renderItems(playlist.items || []); renderItems(playlist.items || []);
showToast('Item removed'); refreshAfterMutation();
showToast(t('playlist.toast.item_removed'));
} catch (err) {
showToast(err.message, 'error');
}
});
});
itemsEl.querySelectorAll('.item-schedule').forEach(btn => {
btn.addEventListener('click', (e) => {
const itemId = e.currentTarget.dataset.itemId;
const item = items.find(it => String(it.id) === String(itemId));
if (item) showScheduleModal(item);
});
});
itemsEl.querySelectorAll('.item-move').forEach(btn => {
btn.addEventListener('click', async (e) => {
if (btn.disabled) return;
const itemId = parseInt(e.currentTarget.dataset.itemId, 10);
const dir = e.currentTarget.dataset.dir;
const order = Array.from(itemsEl.querySelectorAll('.playlist-item'))
.map(el => parseInt(el.dataset.itemId, 10));
const idx = order.indexOf(itemId);
const swap = dir === 'up' ? idx - 1 : idx + 1;
if (swap < 0 || swap >= order.length) return;
[order[idx], order[swap]] = [order[swap], order[idx]];
try {
const updated = await api.reorderPlaylistItems(currentPlaylistId, order);
renderItems(updated);
refreshAfterMutation();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
}); });
}); });
// Drag-to-reorder
setupDragReorder(itemsEl); setupDragReorder(itemsEl);
} }
@ -319,27 +448,23 @@ function setupDragReorder(container) {
const target = e.target.closest('.playlist-item'); const target = e.target.closest('.playlist-item');
if (!target || !dragEl || target === dragEl) return; if (!target || !dragEl || target === dragEl) return;
// Reorder DOM
container.insertBefore(dragEl, target); container.insertBefore(dragEl, target);
// Collect new order
const order = Array.from(container.querySelectorAll('.playlist-item')) const order = Array.from(container.querySelectorAll('.playlist-item'))
.map(el => parseInt(el.dataset.itemId, 10)); .map(el => parseInt(el.dataset.itemId, 10));
try { try {
const items = await api.reorderPlaylistItems(currentPlaylistId, order); const items = await api.reorderPlaylistItems(currentPlaylistId, order);
renderItems(items); renderItems(items);
refreshAfterMutation();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
// Reload to fix state
const playlist = await api.getPlaylist(currentPlaylistId); const playlist = await api.getPlaylist(currentPlaylistId);
renderItems(playlist.items || []); renderItems(playlist.items || []);
} }
}); });
} }
// ==================== INLINE EDIT ====================
function inlineEdit(playlist, field) { function inlineEdit(playlist, field) {
const el = field === 'name' ? document.getElementById('playlistTitle') : document.getElementById('playlistDesc'); const el = field === 'name' ? document.getElementById('playlistTitle') : document.getElementById('playlistDesc');
if (!el) return; if (!el) return;
@ -369,7 +494,7 @@ function inlineEdit(playlist, field) {
const newEl = document.createElement('h1'); const newEl = document.createElement('h1');
newEl.id = 'playlistTitle'; newEl.id = 'playlistTitle';
newEl.style.cursor = 'pointer'; newEl.style.cursor = 'pointer';
newEl.title = 'Click to rename'; newEl.title = t('playlist.click_to_rename');
newEl.textContent = playlist.name; newEl.textContent = playlist.name;
input.replaceWith(newEl); input.replaceWith(newEl);
newEl.addEventListener('click', () => inlineEdit(playlist, 'name')); newEl.addEventListener('click', () => inlineEdit(playlist, 'name'));
@ -397,11 +522,11 @@ function inlineEdit(playlist, field) {
newEl.className = 'subtitle'; newEl.className = 'subtitle';
newEl.id = 'playlistDesc'; newEl.id = 'playlistDesc';
newEl.style.cursor = 'pointer'; newEl.style.cursor = 'pointer';
newEl.title = 'Click to edit description'; newEl.title = t('playlist.click_to_edit_desc');
if (playlist.description) { if (playlist.description) {
newEl.textContent = playlist.description; newEl.textContent = playlist.description;
} else { } else {
newEl.innerHTML = '<span style="opacity:0.5">Add a description...</span>'; newEl.innerHTML = `<span style="opacity:0.5">${t('playlist.add_desc_placeholder')}</span>`;
} }
input.replaceWith(newEl); input.replaceWith(newEl);
newEl.addEventListener('click', () => inlineEdit(playlist, 'description')); newEl.addEventListener('click', () => inlineEdit(playlist, 'description'));
@ -412,22 +537,20 @@ function inlineEdit(playlist, field) {
} }
} }
// ==================== ADD ITEM MODAL ====================
async function showAddItemModal(playlistId) { async function showAddItemModal(playlistId) {
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
modal.innerHTML = ` modal.innerHTML = `
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:560px;max-width:90vw;max-height:80vh;display:flex;flex-direction:column"> <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;max-width:560px;width:95vw;max-height:80vh;display:flex;flex-direction:column">
<h3 style="margin-bottom:16px;color:var(--text-primary)">Add Content to Playlist</h3> <h3 style="margin-bottom:16px;color:var(--text-primary)">${t('playlist.add_modal_title')}</h3>
<div style="display:flex;gap:8px;margin-bottom:12px"> <div style="display:flex;gap:8px;margin-bottom:12px">
<button class="btn btn-primary btn-sm tab-btn active" data-tab="content">Content</button> <button class="btn btn-primary btn-sm tab-btn active" data-tab="content">${t('playlist.tab_content')}</button>
<button class="btn btn-secondary btn-sm tab-btn" data-tab="widgets">Widgets</button> <button class="btn btn-secondary btn-sm tab-btn" data-tab="widgets">${t('playlist.tab_widgets')}</button>
</div> </div>
<input type="text" id="addItemSearch" class="input" placeholder="Search..." style="width:100%;margin-bottom:12px"> <input type="text" id="addItemSearch" class="input" placeholder="${t('playlist.search_placeholder')}" style="width:100%;margin-bottom:12px">
<div id="addItemList" style="flex:1;overflow-y:auto;min-height:200px;max-height:400px"></div> <div id="addItemList" style="flex:1;overflow-y:auto;min-height:200px;max-height:400px"></div>
<div style="display:flex;justify-content:flex-end;margin-top:16px"> <div style="display:flex;justify-content:flex-end;margin-top:16px">
<button class="btn btn-secondary" id="closeAddModal">Close</button> <button class="btn btn-secondary" id="closeAddModal">${t('playlist.close')}</button>
</div> </div>
</div> </div>
`; `;
@ -437,14 +560,13 @@ async function showAddItemModal(playlistId) {
let allContent = []; let allContent = [];
let allWidgets = []; let allWidgets = [];
// Load data
try { try {
[allContent, allWidgets] = await Promise.all([ [allContent, allWidgets] = await Promise.all([
api.getContent(), api.getContent(),
api.getWidgets ? api.getWidgets() : Promise.resolve([]) api.getWidgets ? api.getWidgets() : Promise.resolve([])
]); ]);
} catch (err) { } catch (err) {
document.getElementById('addItemList').innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">Failed to load: ${esc(err.message)}</div>`; document.getElementById('addItemList').innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">${t('playlist.load_failed', { error: esc(err.message) })}</div>`;
} }
function renderTab() { function renderTab() {
@ -457,15 +579,15 @@ async function showAddItemModal(playlistId) {
}); });
if (!filtered.length) { if (!filtered.length) {
list.innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">No ${activeTab} found</div>`; list.innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">${activeTab === 'content' ? t('playlist.no_content_found') : t('playlist.no_widgets_found')}</div>`;
return; return;
} }
list.innerHTML = filtered.map(item => { list.innerHTML = filtered.map(item => {
const isWidget = activeTab === 'widgets'; const isWidget = activeTab === 'widgets';
const name = item.filename || item.name || 'Unknown'; const name = item.filename || item.name || t('common.unknown');
const sub = isWidget ? (item.widget_type || 'Widget') : (item.mime_type || ''); const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : (item.mime_type || '');
const thumb = item.thumbnail_path ? `/uploads/thumbnails/${esc(item.thumbnail_path.split('/').pop())}` : null; const thumb = item.thumbnail_path ? `/api/content/${esc(item.id)}/thumbnail` : null;
return ` return `
<div class="add-item-row" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:var(--radius);cursor:pointer;transition:background 0.1s"> <div class="add-item-row" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:var(--radius);cursor:pointer;transition:background 0.1s">
<div style="width:40px;height:30px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center"> <div style="width:40px;height:30px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center">
@ -475,12 +597,11 @@ async function showAddItemModal(playlistId) {
<div style="font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(name)}</div> <div style="font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(name)}</div>
<div style="font-size:11px;color:var(--text-muted)">${esc(sub)}</div> <div style="font-size:11px;color:var(--text-muted)">${esc(sub)}</div>
</div> </div>
<button class="btn btn-primary btn-sm add-item-btn" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}">Add</button> <button class="btn btn-primary btn-sm add-item-btn" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}">${t('playlist.add_btn')}</button>
</div> </div>
`; `;
}).join(''); }).join('');
// Add button handlers
list.querySelectorAll('.add-item-btn').forEach(btn => { list.querySelectorAll('.add-item-btn').forEach(btn => {
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
@ -489,24 +610,21 @@ async function showAddItemModal(playlistId) {
const data = type === 'widget' ? { widget_id: id } : { content_id: id }; const data = type === 'widget' ? { widget_id: id } : { content_id: id };
try { try {
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Adding...'; btn.textContent = t('playlist.adding');
await api.addPlaylistItem(playlistId, data); await api.addPlaylistItem(playlistId, data);
btn.textContent = 'Added'; btn.textContent = t('playlist.added');
btn.classList.remove('btn-primary'); btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary'); btn.classList.add('btn-secondary');
// Refresh the detail view items refreshAfterMutation();
const playlist = await api.getPlaylist(playlistId);
renderItems(playlist.items || []);
} catch (err) { } catch (err) {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Add'; btn.textContent = t('playlist.add_btn');
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
}); });
}); });
} }
// Tab switching
modal.querySelectorAll('.tab-btn').forEach(btn => { modal.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
activeTab = btn.dataset.tab; activeTab = btn.dataset.tab;
@ -519,12 +637,117 @@ async function showAddItemModal(playlistId) {
}); });
}); });
// Search
document.getElementById('addItemSearch').addEventListener('input', renderTab); document.getElementById('addItemSearch').addEventListener('input', renderTab);
// Close
document.getElementById('closeAddModal').addEventListener('click', () => modal.remove()); document.getElementById('closeAddModal').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
renderTab(); renderTab();
} }
// #74/#75: per-item schedule editor. Multiple blocks (days + time window + optional
// date range) OR together; an item with no blocks always plays. Client validation
// mirrors the server; saving marks the playlist DRAFT (must re-publish to reach devices).
function showScheduleModal(item) {
let blocks = (item.schedules || []).map(b => ({
days: Array.isArray(b.days) ? [...b.days] : [],
start: b.start || '00:00',
end: b.end || '24:00',
start_date: b.start_date || '',
end_date: b.end_date || ''
}));
const modal = document.createElement('div');
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
document.body.appendChild(modal);
function blockRow(b, idx) {
const eod = b.end === '24:00';
const dayLabels = t('itemsched.dow_short').split(',');
return `
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong style="font-size:13px">${t('itemsched.block', { n: idx + 1 })}</strong>
<button class="sched-remove" data-idx="${idx}" title="${t('itemsched.remove_block')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;font-size:14px"></button>
</div>
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px">
${dayLabels.map((lbl, d) => `<button class="sched-day" data-idx="${idx}" data-day="${d}" style="padding:4px 9px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid var(--border);background:${b.days.includes(d) ? 'var(--accent)' : 'var(--bg-input)'};color:${b.days.includes(d) ? '#000' : 'var(--text-muted)'}">${lbl}</button>`).join('')}
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center">
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.from')} <input type="time" class="input sched-start" data-idx="${idx}" value="${esc(b.start)}" style="width:118px"></label>
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.to')} <input type="time" class="input sched-end" data-idx="${idx}" value="${esc(eod ? '00:00' : b.end)}" ${eod ? 'disabled' : ''} style="width:118px"></label>
<label style="font-size:12px;color:var(--text-muted)"><input type="checkbox" class="sched-eod" data-idx="${idx}" ${eod ? 'checked' : ''}> ${t('itemsched.end_of_day')}</label>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-top:10px">
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.starts')} <input type="date" class="input sched-sd" data-idx="${idx}" value="${esc(b.start_date)}" style="width:150px"></label>
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.ends')} <input type="date" class="input sched-ed" data-idx="${idx}" value="${esc(b.end_date)}" style="width:150px"></label>
<span style="font-size:11px;color:var(--text-muted)">${t('itemsched.dates_hint')}</span>
</div>
</div>`;
}
function render() {
modal.innerHTML = `
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:580px;max-width:94vw;max-height:88vh;overflow:auto">
<h3 style="margin:0 0 4px">${t('itemsched.title')}</h3>
<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">${esc(item.filename || item.widget_name || 'item')}</p>
<p style="font-size:12px;color:#7dd3fc;background:#0c2a3f;border-radius:6px;padding:8px 10px;margin:0 0 16px">${t('itemsched.hint')}</p>
<div>${blocks.length ? blocks.map(blockRow).join('') : `<p style="font-size:13px;color:var(--text-muted);margin:0 0 10px">${t('itemsched.none')}</p>`}</div>
<button class="btn btn-secondary btn-sm" id="schedAddBlock" style="margin-bottom:4px">${t('itemsched.add_block')}</button>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:20px">
<button class="btn btn-secondary" id="schedCancel">${t('itemsched.cancel')}</button>
<button class="btn" id="schedSave" style="background:#f59e0b;color:#000;font-weight:600;border:none">${t('itemsched.save')}</button>
</div>
</div>`;
wire();
}
function wire() {
modal.querySelectorAll('.sched-day').forEach(btn => btn.addEventListener('click', () => {
const i = +btn.dataset.idx, d = +btn.dataset.day;
const set = new Set(blocks[i].days);
if (set.has(d)) set.delete(d); else set.add(d);
blocks[i].days = [...set];
render();
}));
modal.querySelectorAll('.sched-start').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].start = el.value; }));
modal.querySelectorAll('.sched-end').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].end = el.value; }));
modal.querySelectorAll('.sched-eod').forEach(el => el.addEventListener('change', () => {
blocks[+el.dataset.idx].end = el.checked ? '24:00' : '17:00';
render();
}));
modal.querySelectorAll('.sched-sd').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].start_date = el.value; }));
modal.querySelectorAll('.sched-ed').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].end_date = el.value; }));
modal.querySelectorAll('.sched-remove').forEach(btn => btn.addEventListener('click', () => { blocks.splice(+btn.dataset.idx, 1); render(); }));
document.getElementById('schedAddBlock').addEventListener('click', () => {
blocks.push({ days: [0, 1, 2, 3, 4, 5, 6], start: '09:00', end: '17:00', start_date: '', end_date: '' });
render();
});
document.getElementById('schedCancel').addEventListener('click', () => modal.remove());
document.getElementById('schedSave').addEventListener('click', doSave);
}
async function doSave() {
const payload = blocks.map(b => ({
days: b.days, start: b.start, end: b.end,
start_date: b.start_date || null, end_date: b.end_date || null
}));
const err = validateScheduleBlocks(payload);
if (err) { showToast(err, 'error'); return; }
try {
const saved = await api.setItemSchedules(currentPlaylistId, item.id, payload);
item.schedules = saved;
modal.remove();
// Saving makes the playlist a DRAFT — surface the re-publish step explicitly.
showToast(payload.length ? t('itemsched.toast.saved') : t('itemsched.toast.cleared'));
const playlist = await api.getPlaylist(currentPlaylistId);
renderItems(playlist.items || []);
refreshAfterMutation();
} catch (e) {
showToast(e.message, 'error');
}
}
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
render();
}

View file

@ -1,6 +1,7 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc } from '../utils.js'; import { esc } from '../utils.js';
import { t } from '../i18n.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
@ -12,36 +13,36 @@ export async function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div><h1>Reports <span class="help-tip" data-tip="Proof-of-play analytics. See what played, when, and on which device. Filter by date range and device. Export to CSV for ad verification.">?</span></h1><div class="subtitle">Proof-of-play analytics and device uptime</div></div> <div><h1>${t('report.title')} <span class="help-tip" data-tip="${t('report.help_tip')}">?</span></h1><div class="subtitle">${t('report.subtitle')}</div></div>
<a class="btn btn-secondary" id="exportBtn"> <a class="btn btn-secondary" id="exportBtn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg> </svg>
Export CSV ${t('report.export_csv')}
</a> </a>
</div> </div>
<div style="display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end"> <div style="display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin:0"><label>Device</label> <div class="form-group" style="margin:0"><label>${t('report.device')}</label>
<select id="reportDevice" class="input" style="width:200px;background:var(--bg-input)"> <select id="reportDevice" class="input" style="width:200px;background:var(--bg-input)">
<option value="">All Devices</option> <option value="">${t('report.all_devices')}</option>
${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')} ${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
</select> </select>
</div> </div>
<div class="form-group" style="margin:0"><label>Start Date</label> <div class="form-group" style="margin:0"><label>${t('report.start_date')}</label>
<input type="date" id="reportStart" class="input" value="${thirtyDaysAgo.toISOString().split('T')[0]}"> <input type="date" id="reportStart" class="input" value="${thirtyDaysAgo.toISOString().split('T')[0]}">
</div> </div>
<div class="form-group" style="margin:0"><label>End Date</label> <div class="form-group" style="margin:0"><label>${t('report.end_date')}</label>
<input type="date" id="reportEnd" class="input" value="${today.toISOString().split('T')[0]}"> <input type="date" id="reportEnd" class="input" value="${today.toISOString().split('T')[0]}">
</div> </div>
<button class="btn btn-primary btn-sm" id="loadReportBtn">Load Report</button> <button class="btn btn-primary btn-sm" id="loadReportBtn">${t('report.load_report')}</button>
</div> </div>
<div id="reportContent"><div class="empty-state"><h3>Select a date range and click Load Report</h3></div></div> <div id="reportContent"><div class="empty-state"><h3>${t('report.select_range')}</h3></div></div>
`; `;
document.getElementById('loadReportBtn').onclick = loadReport; document.getElementById('loadReportBtn').onclick = loadReport;
loadReport(); // Auto-load on page render loadReport();
document.getElementById('exportBtn').onclick = () => { document.getElementById('exportBtn').onclick = () => {
const deviceId = document.getElementById('reportDevice').value; const deviceId = document.getElementById('reportDevice').value;
const start = document.getElementById('reportStart').value; const start = document.getElementById('reportStart').value;
@ -56,81 +57,79 @@ export async function render(container) {
const end = document.getElementById('reportEnd').value; const end = document.getElementById('reportEnd').value;
const content = document.getElementById('reportContent'); const content = document.getElementById('reportContent');
content.innerHTML = '<div class="empty-state"><h3>Loading...</h3></div>'; content.innerHTML = `<div class="empty-state"><h3>${t('common.loading')}</h3></div>`;
try { try {
const summary = await API(`/reports/summary?device_id=${deviceId}&start=${start}&end=${end}`); const summary = await API(`/reports/summary?device_id=${deviceId}&start=${start}&end=${end}`);
content.innerHTML = ` content.innerHTML = `
<!-- Summary Cards -->
<div class="info-grid" style="margin-bottom:24px"> <div class="info-grid" style="margin-bottom:24px">
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Total Plays</div> <div class="info-card-label">${t('report.total_plays')}</div>
<div class="info-card-value">${summary.overall.total_plays.toLocaleString()}</div> <div class="info-card-value">${summary.overall.total_plays.toLocaleString()}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Total Hours</div> <div class="info-card-label">${t('report.total_hours')}</div>
<div class="info-card-value">${summary.overall.total_hours}</div> <div class="info-card-value">${summary.overall.total_hours}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Unique Content</div> <div class="info-card-label">${t('report.unique_content')}</div>
<div class="info-card-value">${summary.overall.unique_content}</div> <div class="info-card-value">${summary.overall.unique_content}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Active Devices</div> <div class="info-card-label">${t('report.active_devices')}</div>
<div class="info-card-value">${summary.overall.unique_devices}</div> <div class="info-card-value">${summary.overall.unique_devices}</div>
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">Avg Duration</div> <div class="info-card-label">${t('report.avg_duration')}</div>
<div class="info-card-value small">${formatDuration(summary.overall.avg_duration_sec)}</div> <div class="info-card-value small">${formatDuration(summary.overall.avg_duration_sec)}</div>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px">
<!-- Plays per Day Chart -->
<div class="settings-section" style="margin:0"> <div class="settings-section" style="margin:0">
<h3 style="font-size:14px;margin-bottom:12px">Plays per Day</h3> <h3 style="font-size:14px;margin-bottom:12px">${t('report.plays_per_day')}</h3>
<div id="dailyChart" style="height:200px;display:flex;align-items:flex-end;gap:2px"></div> <div id="dailyChart" style="height:200px;display:flex;align-items:flex-end;gap:2px"></div>
</div> </div>
<!-- Plays by Hour Chart -->
<div class="settings-section" style="margin:0"> <div class="settings-section" style="margin:0">
<h3 style="font-size:14px;margin-bottom:12px">Plays by Hour</h3> <h3 style="font-size:14px;margin-bottom:12px">${t('report.plays_by_hour')}</h3>
<div id="hourlyChart" style="height:200px;display:flex;align-items:flex-end;gap:1px"></div> <div id="hourlyChart" style="height:200px;display:flex;align-items:flex-end;gap:1px"></div>
</div> </div>
</div> </div>
<!-- Top Content -->
<div class="settings-section" style="margin-bottom:20px"> <div class="settings-section" style="margin-bottom:20px">
<h3 style="font-size:14px;margin-bottom:12px">Top Content</h3> <h3 style="font-size:14px;margin-bottom:12px">${t('report.top_content')}</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px"> <div class="table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:460px">
<thead><tr style="border-bottom:1px solid var(--border)"> <thead><tr style="border-bottom:1px solid var(--border)">
<th style="padding:8px;text-align:left;color:var(--text-muted)">Content</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">${t('report.col.content')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Plays</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.plays')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Total Hours</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.total_hours')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Completion</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.completion')}</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
${summary.by_content.map(c => ` ${summary.by_content.map(c => `
<tr style="border-bottom:1px solid var(--border)"> <tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">${c.content_name || 'Unknown'}</td> <td style="padding:8px">${c.content_name || t('common.unknown')}</td>
<td style="padding:8px;text-align:right">${c.plays}</td> <td style="padding:8px;text-align:right">${c.plays}</td>
<td style="padding:8px;text-align:right">${(c.total_seconds / 3600).toFixed(1)}</td> <td style="padding:8px;text-align:right">${(c.total_seconds / 3600).toFixed(1)}</td>
<td style="padding:8px;text-align:right">${c.plays > 0 ? Math.round((c.completed_plays / c.plays) * 100) : 0}%</td> <td style="padding:8px;text-align:right">${c.plays > 0 ? Math.round((c.completed_plays / c.plays) * 100) : 0}%</td>
</tr> </tr>
`).join('') || '<tr><td colspan="4" style="padding:16px;text-align:center;color:var(--text-muted)">No data</td></tr>'} `).join('') || `<tr><td colspan="4" style="padding:16px;text-align:center;color:var(--text-muted)">${t('report.no_data')}</td></tr>`}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<!-- By Device -->
<div class="settings-section"> <div class="settings-section">
<h3 style="font-size:14px;margin-bottom:12px">By Device</h3> <h3 style="font-size:14px;margin-bottom:12px">${t('report.by_device')}</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px"> <div class="table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:400px">
<thead><tr style="border-bottom:1px solid var(--border)"> <thead><tr style="border-bottom:1px solid var(--border)">
<th style="padding:8px;text-align:left;color:var(--text-muted)">Device</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">${t('report.col.device')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Plays</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.plays')}</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Total Hours</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.total_hours')}</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
${summary.by_device.map(d => ` ${summary.by_device.map(d => `
@ -139,19 +138,18 @@ export async function render(container) {
<td style="padding:8px;text-align:right">${d.plays}</td> <td style="padding:8px;text-align:right">${d.plays}</td>
<td style="padding:8px;text-align:right">${(d.total_seconds / 3600).toFixed(1)}</td> <td style="padding:8px;text-align:right">${(d.total_seconds / 3600).toFixed(1)}</td>
</tr> </tr>
`).join('') || '<tr><td colspan="3" style="padding:16px;text-align:center;color:var(--text-muted)">No data</td></tr>'} `).join('') || `<tr><td colspan="3" style="padding:16px;text-align:center;color:var(--text-muted)">${t('report.no_data')}</td></tr>`}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
`; `;
// Render daily chart
renderBarChart('dailyChart', summary.by_day.map(d => ({ renderBarChart('dailyChart', summary.by_day.map(d => ({
label: new Date(d.day).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), label: new Date(d.day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
value: d.plays value: d.plays
}))); })));
// Render hourly chart
const hourData = Array.from({ length: 24 }, (_, i) => { const hourData = Array.from({ length: 24 }, (_, i) => {
const found = summary.by_hour.find(h => h.hour === i); const found = summary.by_hour.find(h => h.hour === i);
return { label: i === 0 ? '12a' : i < 12 ? i + 'a' : i === 12 ? '12p' : (i - 12) + 'p', value: found?.plays || 0 }; return { label: i === 0 ? '12a' : i < 12 ? i + 'a' : i === 12 ? '12p' : (i - 12) + 'p', value: found?.plays || 0 };
@ -159,7 +157,7 @@ export async function render(container) {
renderBarChart('hourlyChart', hourData); renderBarChart('hourlyChart', hourData);
} catch (err) { } catch (err) {
content.innerHTML = `<div class="empty-state"><h3>Error</h3><p>${esc(err.message)}</p></div>`; content.innerHTML = `<div class="empty-state"><h3>${t('report.error')}</h3><p>${esc(err.message)}</p></div>`;
} }
} }
} }

View file

@ -1,72 +1,118 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
const HOURS = Array.from({ length: 24 }, (_, i) => i); const HOURS = Array.from({ length: 24 }, (_, i) => i);
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function esc(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
export async function render(container) { export async function render(container) {
const devices = await api.getDevices(); const [devices, content, groups, playlists, layoutsRaw] = await Promise.all([
const content = await api.getContent(); api.getDevices(),
const selectedDevice = devices[0]?.id || ''; api.getContent(),
api.getGroups(),
api.getPlaylists(),
API('/layouts'),
]);
const layouts = (Array.isArray(layoutsRaw) ? layoutsRaw : []).filter(l => !l.is_template);
const today = new Date(); const today = new Date();
const weekStart = new Date(today); const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay()); weekStart.setDate(today.getDate() - today.getDay());
weekStart.setHours(0, 0, 0, 0); weekStart.setHours(0, 0, 0, 0);
const DAYS = [
t('schedule.day.sun'), t('schedule.day.mon'), t('schedule.day.tue'),
t('schedule.day.wed'), t('schedule.day.thu'), t('schedule.day.fri'),
t('schedule.day.sat'),
];
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div><h1>Schedule <span class="help-tip" data-tip="Visual weekly calendar for content scheduling. Click Add Schedule to create time slots. Set recurrence for repeating content. Higher priority overrides lower.">?</span></h1><div class="subtitle">Content scheduling calendar</div></div> <div><h1>${t('schedule.title')} <span class="help-tip" data-tip="${t('schedule.help_tip')}">?</span></h1><div class="subtitle">${t('schedule.subtitle')}</div></div>
</div> </div>
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center"> <div class="schedule-controls" style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<select id="schedDevice" class="input" style="width:200px;background:var(--bg-input)"> <select id="schedDevice" class="input" style="width:200px;max-width:100%;background:var(--bg-input)">
${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')} ${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
</select> </select>
<button class="btn btn-secondary btn-sm" id="prevWeek">&lt; Prev</button> <button class="btn btn-secondary btn-sm" id="prevWeek">${t('schedule.prev_week')}</button>
<span id="weekLabel" style="color:var(--text-secondary);font-size:13px"></span> <span id="weekLabel" style="color:var(--text-secondary);font-size:13px"></span>
<button class="btn btn-secondary btn-sm" id="nextWeek">Next &gt;</button> <button class="btn btn-secondary btn-sm" id="nextWeek">${t('schedule.next_week')}</button>
<button class="btn btn-primary btn-sm" id="addScheduleBtn">Add Schedule</button> <button class="btn btn-primary btn-sm" id="addScheduleBtn">${t('schedule.add_schedule')}</button>
</div> </div>
<div style="overflow-x:auto"> <div style="overflow-x:auto">
<div id="calendar" style="display:grid;grid-template-columns:60px repeat(7,1fr);min-width:800px;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden"></div> <div id="calendar" style="display:grid;grid-template-columns:60px repeat(7,1fr);min-width:800px;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden"></div>
</div> </div>
<!-- Add/Edit Schedule Modal -->
<div class="modal-overlay" id="scheduleModal" style="display:none"> <div class="modal-overlay" id="scheduleModal" style="display:none">
<div class="modal" style="width:480px"> <div class="modal" style="width:480px">
<div class="modal-header"><h3 id="schedModalTitle">Add Schedule</h3> <div class="modal-header"><h3 id="schedModalTitle">${t('schedule.add_schedule')}</h3>
<button class="btn-icon" onclick="document.getElementById('scheduleModal').style.display='none'"> <button class="btn-icon" onclick="document.getElementById('scheduleModal').style.display='none'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"><label>Content</label> <div class="form-group"><label>${t('schedule.apply_to')}</label>
<div style="display:flex;gap:16px;margin-bottom:8px">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
<input type="radio" name="schedTarget" value="device" checked id="schedTargetDevice"> ${t('schedule.target_device')}
</label>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
<input type="radio" name="schedTarget" value="group" id="schedTargetGroup"> ${t('schedule.target_group')}
</label>
</div>
<select id="schedDeviceSelect" class="input" style="background:var(--bg-input)">
${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
</select>
<select id="schedGroupSelect" class="input" style="background:var(--bg-input);display:none">
${groups.map(g => `<option value="${esc(g.id)}">${esc(g.name)} (${t('schedule.group_devices_count', { n: g.device_count })})</option>`).join('')}
</select>
${groups.length === 0 ? `<div id="schedNoGroups" style="display:none;color:var(--text-muted);font-size:12px;margin-top:4px">${t('schedule.no_groups_msg')}</div>` : ''}
<div id="schedZoneNote" style="display:none;color:var(--text-muted);font-size:11px;margin-top:4px">${t('schedule.zone_note')}</div>
</div>
<div class="form-group"><label>${t('schedule.playlist_override')}</label>
<select id="schedPlaylist" class="input" style="background:var(--bg-input)">
<option value="">${t('schedule.no_playlist_override')}</option>
${playlists.map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' ' + t('schedule.draft_suffix') : ''}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>${t('schedule.layout_override')}</label>
<select id="schedLayout" class="input" style="background:var(--bg-input)">
<option value="">${t('schedule.no_layout_override')}</option>
${layouts.map(l => `<option value="${esc(l.id)}">${esc(l.name)}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>${t('schedule.content_label')} <span style="color:var(--text-muted);font-weight:normal;font-size:11px">${t('schedule.content_hint')}</span></label>
<select id="schedContent" class="input" style="background:var(--bg-input)"> <select id="schedContent" class="input" style="background:var(--bg-input)">
${content.map(c => `<option value="${c.id}">${c.filename}</option>`).join('')} <option value="">${t('schedule.content_none')}</option>
${content.map(c => `<option value="${esc(c.id)}">${esc(c.filename)}</option>`).join('')}
</select> </select>
</div> </div>
<div class="form-group"><label>Title (optional)</label><input type="text" id="schedTitle" class="input" placeholder="e.g., Morning Playlist"></div> <div class="form-group"><label>${t('schedule.title_label')}</label><input type="text" id="schedTitle" class="input" placeholder="${t('schedule.title_placeholder')}"></div>
<div style="display:flex;gap:12px"> <div style="display:flex;gap:12px">
<div class="form-group" style="flex:1"><label>Start Time</label><input type="time" id="schedStart" class="input" value="09:00"></div> <div class="form-group" style="flex:1"><label>${t('schedule.start_time')}</label><input type="time" id="schedStart" class="input" value="09:00"></div>
<div class="form-group" style="flex:1"><label>End Time</label><input type="time" id="schedEnd" class="input" value="17:00"></div> <div class="form-group" style="flex:1"><label>${t('schedule.end_time')}</label><input type="time" id="schedEnd" class="input" value="17:00"></div>
</div> </div>
<div class="form-group"><label>Repeat</label> <div class="form-group"><label>${t('schedule.repeat')}</label>
<select id="schedRepeat" class="input" style="background:var(--bg-input)"> <select id="schedRepeat" class="input" style="background:var(--bg-input)">
<option value="">No repeat</option> <option value="">${t('schedule.repeat_none')}</option>
<option value="FREQ=DAILY">Daily</option> <option value="FREQ=DAILY">${t('schedule.repeat_daily')}</option>
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">Weekdays</option> <option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">${t('schedule.repeat_weekdays')}</option>
<option value="FREQ=WEEKLY;BYDAY=SA,SU">Weekends</option> <option value="FREQ=WEEKLY;BYDAY=SA,SU">${t('schedule.repeat_weekends')}</option>
<option value="FREQ=WEEKLY">Weekly</option> <option value="FREQ=WEEKLY">${t('schedule.repeat_weekly')}</option>
</select> </select>
</div> </div>
<div class="form-group"><label>Priority</label><input type="number" id="schedPriority" class="input" value="0" min="0" max="100"></div> <div class="form-group"><label>${t('schedule.priority')}</label><input type="number" id="schedPriority" class="input" value="0" min="0" max="100"></div>
<div class="form-group"><label>Color</label><input type="color" id="schedColor" value="#3B82F6" style="width:60px;height:32px;border:none;cursor:pointer"></div> <div class="form-group"><label>${t('schedule.color')}</label><input type="color" id="schedColor" value="#3B82F6" style="width:60px;height:32px;border:none;cursor:pointer"></div>
</div>
<div class="modal-footer" style="display:flex;justify-content:space-between;gap:8px">
<button class="btn btn-danger" id="deleteScheduleBtn" style="display:none">${t('common.delete')}</button>
<div style="display:flex;gap:8px;margin-left:auto">
<button class="btn btn-secondary" onclick="document.getElementById('scheduleModal').style.display='none'">${t('common.cancel')}</button>
<button class="btn btn-primary" id="saveScheduleBtn">${t('common.save')}</button>
</div> </div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="document.getElementById('scheduleModal').style.display='none'">Cancel</button>
<button class="btn btn-primary" id="saveScheduleBtn">Save</button>
</div> </div>
</div> </div>
</div> </div>
@ -75,11 +121,29 @@ export async function render(container) {
let currentWeekStart = new Date(weekStart); let currentWeekStart = new Date(weekStart);
let editingId = null; let editingId = null;
const deviceRadio = document.getElementById('schedTargetDevice');
const groupRadio = document.getElementById('schedTargetGroup');
const deviceSelect = document.getElementById('schedDeviceSelect');
const groupSelect = document.getElementById('schedGroupSelect');
const noGroupsMsg = document.getElementById('schedNoGroups');
const zoneNote = document.getElementById('schedZoneNote');
function updateTargetVisibility() {
const isGroup = groupRadio.checked;
deviceSelect.style.display = isGroup ? 'none' : '';
groupSelect.style.display = isGroup ? '' : 'none';
if (noGroupsMsg) noGroupsMsg.style.display = (isGroup && groups.length === 0) ? '' : 'none';
zoneNote.style.display = isGroup ? '' : 'none';
}
deviceRadio.addEventListener('change', updateTargetVisibility);
groupRadio.addEventListener('change', updateTargetVisibility);
function updateWeekLabel() { function updateWeekLabel() {
const end = new Date(currentWeekStart); const end = new Date(currentWeekStart);
end.setDate(end.getDate() + 6); end.setDate(end.getDate() + 6);
document.getElementById('weekLabel').textContent = document.getElementById('weekLabel').textContent =
`${currentWeekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; `${currentWeekStart.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`;
} }
async function loadCalendar() { async function loadCalendar() {
@ -92,7 +156,6 @@ export async function render(container) {
const cal = document.getElementById('calendar'); const cal = document.getElementById('calendar');
let html = '<div style="background:var(--bg-secondary);border-bottom:1px solid var(--border)"></div>'; let html = '<div style="background:var(--bg-secondary);border-bottom:1px solid var(--border)"></div>';
// Day headers
for (let d = 0; d < 7; d++) { for (let d = 0; d < 7; d++) {
const date = new Date(currentWeekStart); const date = new Date(currentWeekStart);
date.setDate(date.getDate() + d); date.setDate(date.getDate() + d);
@ -103,9 +166,8 @@ export async function render(container) {
</div>`; </div>`;
} }
// Hour rows
for (const h of HOURS) { for (const h of HOURS) {
html += `<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);border-bottom:1px solid var(--border);text-align:right">${h === 0 ? '12am' : h < 12 ? h + 'am' : h === 12 ? '12pm' : (h - 12) + 'pm'}</div>`; html += `<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);border-bottom:1px solid var(--border);text-align:right">${h === 0 ? t('schedule.hour_12am') : h < 12 ? h + t('schedule.hour_am') : h === 12 ? t('schedule.hour_12pm') : (h - 12) + t('schedule.hour_pm')}</div>`;
for (let d = 0; d < 7; d++) { for (let d = 0; d < 7; d++) {
html += `<div style="position:relative;min-height:28px;border-bottom:1px solid var(--border);border-left:1px solid var(--border);background:var(--bg-primary)" data-hour="${h}" data-day="${d}"></div>`; html += `<div style="position:relative;min-height:28px;border-bottom:1px solid var(--border);border-left:1px solid var(--border);background:var(--bg-primary)" data-hour="${h}" data-day="${d}"></div>`;
} }
@ -113,7 +175,6 @@ export async function render(container) {
cal.innerHTML = html; cal.innerHTML = html;
// Render events
events.forEach(ev => { events.forEach(ev => {
const start = new Date(ev.instance_start || ev.start_time); const start = new Date(ev.instance_start || ev.start_time);
const end = new Date(ev.instance_end || ev.end_time); const end = new Date(ev.instance_end || ev.end_time);
@ -125,12 +186,17 @@ export async function render(container) {
const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`); const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`);
if (!cell) return; if (!cell) return;
const isGroupSchedule = !!ev.group_id;
const block = document.createElement('div'); const block = document.createElement('div');
const topOffset = (startHour - Math.floor(startHour)) * 28; const topOffset = (startHour - Math.floor(startHour)) * 28;
block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px; block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px;
background:${ev.color || '#3B82F6'};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.85`; background:${ev.color || '#3B82F6'};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.85;
block.textContent = ev.title || ev.content_name || ev.widget_name || 'Scheduled'; ${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.6);' : ''}`;
block.title = `${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}`;
const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || t('schedule.scheduled_label');
const prefix = isGroupSchedule ? `[${esc(ev.group_name || t('schedule.target_group'))}] ` : '';
block.textContent = prefix + label;
block.title = `${isGroupSchedule ? t('schedule.tooltip_group_prefix') + (ev.group_name || '') + '\n' : ''}${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}\n${t('schedule.tooltip_priority', { n: ev.priority })}`;
block.onclick = () => editSchedule(ev); block.onclick = () => editSchedule(ev);
cell.appendChild(block); cell.appendChild(block);
}); });
@ -138,7 +204,9 @@ export async function render(container) {
function editSchedule(ev) { function editSchedule(ev) {
editingId = ev.id; editingId = ev.id;
document.getElementById('schedModalTitle').textContent = 'Edit Schedule'; document.getElementById('schedModalTitle').textContent = t('schedule.edit_schedule');
document.getElementById('schedPlaylist').value = ev.playlist_id || '';
document.getElementById('schedLayout').value = ev.layout_id || '';
document.getElementById('schedContent').value = ev.content_id || ''; document.getElementById('schedContent').value = ev.content_id || '';
document.getElementById('schedTitle').value = ev.title || ''; document.getElementById('schedTitle').value = ev.title || '';
const start = new Date(ev.start_time); const start = new Date(ev.start_time);
@ -148,26 +216,66 @@ export async function render(container) {
document.getElementById('schedRepeat').value = ev.recurrence || ''; document.getElementById('schedRepeat').value = ev.recurrence || '';
document.getElementById('schedPriority').value = ev.priority || 0; document.getElementById('schedPriority').value = ev.priority || 0;
document.getElementById('schedColor').value = ev.color || '#3B82F6'; document.getElementById('schedColor').value = ev.color || '#3B82F6';
if (ev.group_id) {
groupRadio.checked = true;
groupSelect.value = ev.group_id;
} else {
deviceRadio.checked = true;
deviceSelect.value = ev.device_id || document.getElementById('schedDevice').value;
}
updateTargetVisibility();
document.getElementById('deleteScheduleBtn').style.display = '';
document.getElementById('scheduleModal').style.display = 'flex'; document.getElementById('scheduleModal').style.display = 'flex';
} }
document.getElementById('addScheduleBtn').onclick = () => { document.getElementById('addScheduleBtn').onclick = () => {
editingId = null; editingId = null;
document.getElementById('schedModalTitle').textContent = 'Add Schedule'; document.getElementById('schedModalTitle').textContent = t('schedule.add_schedule');
document.getElementById('schedTitle').value = ''; document.getElementById('schedTitle').value = '';
document.getElementById('schedPlaylist').value = '';
document.getElementById('schedLayout').value = '';
document.getElementById('schedContent').value = '';
deviceRadio.checked = true;
deviceSelect.value = document.getElementById('schedDevice').value;
updateTargetVisibility();
document.getElementById('deleteScheduleBtn').style.display = 'none';
document.getElementById('scheduleModal').style.display = 'flex'; document.getElementById('scheduleModal').style.display = 'flex';
}; };
document.getElementById('deleteScheduleBtn').onclick = async () => {
if (!editingId) return;
if (!confirm(t('schedule.confirm_delete') || 'Delete this schedule?')) return;
try {
await API(`/schedules/${editingId}`, { method: 'DELETE' });
document.getElementById('scheduleModal').style.display = 'none';
showToast(t('schedule.toast.deleted') || 'Schedule deleted', 'success');
loadCalendar();
} catch (err) {
showToast(err.message, 'error');
}
};
document.getElementById('saveScheduleBtn').onclick = async () => { document.getElementById('saveScheduleBtn').onclick = async () => {
const deviceId = document.getElementById('schedDevice').value; const isGroup = groupRadio.checked;
const contentId = document.getElementById('schedContent').value; const contentId = document.getElementById('schedContent').value;
const startTime = document.getElementById('schedStart').value; const startTime = document.getElementById('schedStart').value;
const endTime = document.getElementById('schedEnd').value; const endTime = document.getElementById('schedEnd').value;
if (isGroup && groups.length === 0) {
showToast(t('schedule.toast.no_groups'), 'error');
return;
}
const playlistId = document.getElementById('schedPlaylist').value;
const layoutId = document.getElementById('schedLayout').value;
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const data = { const data = {
device_id: deviceId, content_id: contentId || null,
content_id: contentId, playlist_id: playlistId || null,
layout_id: layoutId || null,
title: document.getElementById('schedTitle').value, title: document.getElementById('schedTitle').value,
start_time: `${today}T${startTime}:00`, start_time: `${today}T${startTime}:00`,
end_time: `${today}T${endTime}:00`, end_time: `${today}T${endTime}:00`,
@ -176,6 +284,12 @@ export async function render(container) {
color: document.getElementById('schedColor').value, color: document.getElementById('schedColor').value,
}; };
if (isGroup) {
data.group_id = groupSelect.value;
} else {
data.device_id = deviceSelect.value;
}
try { try {
if (editingId) { if (editingId) {
await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) }); await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) });
@ -183,7 +297,7 @@ export async function render(container) {
await API('/schedules', { method: 'POST', body: JSON.stringify(data) }); await API('/schedules', { method: 'POST', body: JSON.stringify(data) });
} }
document.getElementById('scheduleModal').style.display = 'none'; document.getElementById('scheduleModal').style.display = 'none';
showToast('Schedule saved', 'success'); showToast(t('schedule.toast.saved'), 'success');
loadCalendar(); loadCalendar();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');

Some files were not shown because too many files have changed in this diff Show more