Compare commits

..

No commits in common. "main" and "pre-phase3" have entirely different histories.

214 changed files with 3361 additions and 36561 deletions

View file

@ -1,38 +0,0 @@
# 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

View file

@ -1,47 +0,0 @@
# 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

View file

@ -1,126 +0,0 @@
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.

View file

@ -1,173 +0,0 @@
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,12 +1,10 @@
# Dependencies # Dependencies
node_modules/ node_modules/
# Databases: SQLite files, WAL/SHM sidecars, and any .db.<suffix> backups # Database
# (e.g. .db.devbak), anywhere in the tree - never commit a database. server/db/*.db
*.db server/db/*.db-wal
*.db-wal server/db/*.db-shm
*.db-shm
*.db.*
# Uploads (user content) # Uploads (user content)
server/uploads/ server/uploads/
@ -26,8 +24,6 @@ android/local.properties
android/release-key.jks android/release-key.jks
*.apk *.apk
*.aab *.aab
*.wgt
*.tar.gz
# IDE / Editor # IDE / Editor
.claude/ .claude/
@ -44,8 +40,3 @@ 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/

View file

@ -1,59 +0,0 @@
# 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.

View file

@ -1,37 +0,0 @@
# 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,79 +1,31 @@
# ScreenTinker # ScreenTinker
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. Open-source digital signage management software. Control content on TVs, displays, and kiosks from anywhere.
**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; draft/publish workflow with revert-to-published - **Playlists** — first-class playlist objects: create, reorder, set per-item duration, share one playlist across multiple displays
- **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 - **Device groups** — organize displays into groups, bulk-assign content or playlists, send bulk commands (reboot, screen on/off, launch, update, shutdown)
- **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-based conflict resolution, both device-level and group-level schedules (device-level overrides win over group-level), timezone support - **Scheduling** — visual weekly calendar with recurrence rules (daily/weekly/monthly), priority levels, timezone support, and playlist overrides
- **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) - **Content designer** — clocks, weather, RSS tickers, countdowns, QR codes
- **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
- **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 - **Alerts** — email notifications when devices go offline
- **Mobile-responsive** — full management dashboard and landing page work on phones and tablets - **Teams** — multi-user with owner, editor, and viewer roles; team-based device access
- **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, Unicode-safe filenames (NFC normalization + UTF-8 multipart decoding) - **Content management** — folder organization, remote URL content (no upload needed), YouTube embeds, video duration detection via ffprobe, automatic thumbnail generation
- **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.
@ -82,9 +34,8 @@ Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, a
### Requirements ### Requirements
- Node.js **20.6+** (the npm scripts use the built-in `--env-file-if-exists` flag, added in 20.6) - Node.js 20+
- 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
@ -92,47 +43,27 @@ 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 npm start SELF_HOSTED=true node server.js
``` ```
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` |
| `DISABLE_REGISTRATION` | Block new account creation (including OAuth auto-signup). First-user setup on an empty DB is still allowed. | `false` | | `APP_URL` | Your public URL (used for Stripe callbacks) | _(none)_ |
| `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.
@ -184,42 +115,15 @@ 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 (Microsoft Graph) #### Email Alerts
Send email notifications when devices go offline. Backed by Microsoft Graph Mail.Send via the client-credentials flow. Send email notifications when devices go offline.
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
| `GRAPH_TENANT_ID` | Microsoft Azure AD tenant ID | | `EMAIL_WEBHOOK_URL` | POST endpoint that sends emails. Receives JSON: `{ to, subject, body }` |
| `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`) |
**Azure AD app setup:** You can point this at any email sending service (SendGrid, Mailgun, a simple SMTP relay, etc.) via a small webhook adapter.
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
@ -251,22 +155,9 @@ 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
@ -314,64 +205,43 @@ To update a running instance to the latest version:
```bash ```bash
cd /opt/screentinker cd /opt/screentinker
# Upgrade to the latest tagged release. Backs up the db (a .backup snapshot under # Back up the database first
# ./backups), checks out the tag, runs npm ci --omit=dev, restarts the service, sqlite3 server/db/remote_display.db ".backup server/db/backup-$(date +%F).db"
# and reports the running version.
scripts/upgrade.sh
# ...or pin a specific release: # Pull latest code
scripts/upgrade.sh v1.8.0 git pull origin main
# Install any new dependencies
cd server && npm install --production
# Restart the service
sudo systemctl restart screentinker
``` ```
Set `SERVICE_NAME` if your systemd unit is not named `screentinker`. If you deployed without git, you can initialize it:
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 --tags git fetch origin main
git checkout -f main git checkout origin/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` and uploaded content is in The SQLite database is at `server/db/remote_display.db`. Back it up regularly:
`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"
``` ```
**Recommended: nightly automated backups** via `scripts/backup.sh`. It takes an Uploaded content is in `server/uploads/`. Back that up too.
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
@ -403,13 +273,6 @@ 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
@ -426,49 +289,9 @@ 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
``` ```
@ -492,25 +315,11 @@ scripts/ Device setup scripts + admin recovery
## Tech Stack ## Tech Stack
- **Backend:** Node.js 20.6+, Express, Socket.IO, SQLite (better-sqlite3) - **Backend:** Node.js, Express, Socket.IO, SQLite (better-sqlite3)
- **Frontend:** Vanilla JS SPA (no framework, no build step), ES modules, Service Worker for offline support - **Frontend:** Vanilla JS SPA (no framework, no build step)
- **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

View file

@ -1,64 +0,0 @@
# 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).

View file

@ -1,97 +0,0 @@
# 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.9.1-beta1 1.7.7

View file

@ -11,8 +11,8 @@ android {
applicationId = "com.remotedisplay.player" applicationId = "com.remotedisplay.player"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 21 versionCode = 10
versionName = "1.9.1-beta1" versionName = "1.7.7"
} }
signingConfigs { signingConfigs {
@ -21,9 +21,6 @@ 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).
} }
} }
@ -78,50 +75,4 @@ 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,15 +14,12 @@
<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="false" android:allowBackup="true"
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">
@ -69,21 +66,11 @@
android:screenOrientation="landscape" android:screenOrientation="landscape"
android:theme="@style/Theme.RemoteDisplay.Fullscreen" /> android:theme="@style/Theme.RemoteDisplay.Fullscreen" />
<!-- WebSocket foreground service. #5: declares ONLY mediaPlayback - the <!-- WebSocket foreground service -->
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" /> android:foregroundServiceType="mediaPlayback|mediaProjection" />
<!-- #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
@ -110,18 +97,6 @@
</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,7 +52,6 @@ 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
@ -101,9 +100,6 @@ 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 = (
@ -137,10 +133,8 @@ 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) } },
// #74/#75: clear the last frame when going idle (else a now-filtered item lingers on screen) onPlaylistEmpty = { showStatus("Waiting for content...") },
onPlaylistEmpty = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) }, onRequestRefresh = { wsService?.requestPlaylistRefresh() }
onRequestRefresh = { wsService?.requestPlaylistRefresh() },
onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) }
) )
// Setup media player // Setup media player
@ -150,38 +144,10 @@ 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 {
@ -203,40 +169,9 @@ 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")
@ -249,14 +184,6 @@ 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")
@ -273,7 +200,6 @@ 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 {
@ -297,7 +223,6 @@ 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)
} }
@ -306,12 +231,7 @@ 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)
// Widget assignments have no downloadable content file - skip val contentId = item.getString("content_id")
// (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)
@ -338,12 +258,9 @@ class MainActivity : AppCompatActivity() {
} }
} }
// Start or resume playback after downloads complete — but ONLY in // Start or resume playback after downloads complete
// 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 {
if (zoneManager?.hasZones() != true) playlistController.startIfNeeded() playlistController.startIfNeeded()
} }
} }
} // end else (not suspended) } // end else (not suspended)
@ -355,20 +272,6 @@ 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 = {
@ -457,7 +360,6 @@ 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)
@ -469,18 +371,6 @@ 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,7 +34,6 @@ 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?) {
@ -74,7 +73,6 @@ 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()) {
@ -137,15 +135,9 @@ 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() ?: "------"
// The instruction is shown once, inside the pairing section; don't statusText.text = "Enter this code in the dashboard to pair this display"
// duplicate it in statusText.
statusText.text = ""
connectBtn.isEnabled = false connectBtn.isEnabled = false
} }
} }

View file

@ -10,9 +10,6 @@ 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() {
@ -22,19 +19,16 @@ 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 manager = getSystemService(NotificationManager::class.java) val channel = NotificationChannel(
manager.createNotificationChannel( CHANNEL_ID,
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).apply { CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "ScreenTinker background service" description = "ScreenTinker background service"
setShowBadge(false) setShowBadge(false)
} }
) val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel( manager.createNotificationChannel(channel)
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.MediaProjectionService import com.remotedisplay.player.service.ScreenCaptureService
/** /**
* Transparent activity that requests MediaProjection permission. * Transparent activity that requests MediaProjection permission.
@ -50,11 +50,8 @@ class ScreenCapturePermissionActivity : Activity() {
Companion.resultData = data?.clone() as? Intent Companion.resultData = data?.clone() as? Intent
Companion.hasPermission = true Companion.hasPermission = true
// #5: hand the consent to the dedicated mediaProjection foreground // Tell the service to start the projection
// service. It must enter the foreground with the mediaProjection FGS ScreenCaptureService.startProjection(this, resultCode, data)
// 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,15 +2,11 @@ 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
@ -30,15 +26,8 @@ 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)
@ -51,9 +40,6 @@ 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
@ -89,60 +75,11 @@ 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 = Uri.parse("package:$packageName") data = android.net.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()
@ -193,27 +130,6 @@ 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,13 +62,4 @@ 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,7 +11,6 @@ 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(
@ -19,13 +18,12 @@ 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, WIDGET } enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE }
init { init {
setupExoPlayer() setupExoPlayer()
@ -55,30 +53,13 @@ class MediaPlayerManager(
exoPlayer?.stop() exoPlayer?.stop()
youtubeWebView?.apply { youtubeWebView?.apply {
com.remotedisplay.player.util.WebViewSupport.configure(this, "YouTube") settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
webViewClient = WebViewClient()
webChromeClient = WebChromeClient()
setBackgroundColor(android.graphics.Color.BLACK) setBackgroundColor(android.graphics.Color.BLACK)
// Load via an embed wrapper with a valid youtube.com origin (Error 153 fix). loadUrl(embedUrl)
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)
} }
} }
@ -108,16 +89,20 @@ class MediaPlayerManager(
exoPlayer?.stop() exoPlayer?.stop()
// Load image from URL in background
Thread { Thread {
val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context)) try {
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.post { imageView.setImageBitmap(bitmap) }
try { imageView.setImageBitmap(bitmap) }
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
} }
} else { } catch (e: Exception) {
Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url") Log.e("MediaPlayerManager", "Remote image load failed: ${e.message}")
imageView.post { onImageError?.invoke() }
} }
}.start() }.start()
} }
@ -143,23 +128,24 @@ 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()
val bitmap = ImageLoader.decodeFile(file, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context)) // Load image
if (bitmap == null) {
Log.w("MediaPlayerManager", "Skipping unloadable image: ${file.name}")
onImageError?.invoke()
return
}
try { try {
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
if (bitmap != null) {
imageView.setImageBitmap(bitmap) imageView.setImageBitmap(bitmap)
} catch (e: Throwable) { } else {
Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}") Log.e("MediaPlayerManager", "Failed to decode image: ${file.absolutePath}")
onImageError?.invoke() }
} catch (e: Exception) {
Log.e("MediaPlayerManager", "Error loading image: ${e.message}")
} }
} }

View file

@ -17,36 +17,24 @@ 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
@ -63,8 +51,7 @@ class PlaylistController(
newItems.add( newItems.add(
PlaylistItem( PlaylistItem(
assignmentId = obj.optInt("id", 0), assignmentId = obj.optInt("id", 0),
// Tolerant: widget assignments have no content_id (getString threw). contentId = obj.getString("content_id"),
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", ""),
@ -73,24 +60,14 @@ 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 (key on content OR widget id, since // Check if playlist actually changed
// widget items share an empty contentId). val oldContentIds = items.map { it.contentId }
// #74/#75: a schedule edit changes playback even when content is identical, so val newContentIds = newItems.map { it.contentId }
// 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()) {
@ -121,10 +98,9 @@ class PlaylistController(
return return
} }
} }
// Current item was removed or nothing was playing - start from the first // Current item was removed or nothing was playing - start from beginning
// schedule-active item; idle if none are active right now. currentIndex = 0
val idx = firstActiveIndex() playCurrentItem()
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled()
} else { } else {
currentIndex = 0 currentIndex = 0
} }
@ -146,12 +122,12 @@ class PlaylistController(
fun start() { fun start() {
isRunning = true isRunning = true
if (items.isEmpty()) { onPlaylistEmpty(); return } if (items.isNotEmpty()) {
// #74/#75: begin on the first schedule-active item; idle if none. if (currentIndex < 0) currentIndex = 0
val idx = firstActiveIndex()
if (idx < 0) { showNothingScheduled(); return }
currentIndex = idx
playCurrentItem() playCurrentItem()
} else {
onPlaylistEmpty()
}
} }
fun startIfNeeded() { fun startIfNeeded() {
@ -172,17 +148,13 @@ 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()
} }
@ -193,14 +165,12 @@ 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 and widgets, auto-advance after duration. For videos, wait // For images, auto-advance after duration. For videos, wait for completion callback.
// for the completion callback. if (item.mimeType.startsWith("image/")) {
if (item.mimeType.startsWith("image/") || item.isWidget) {
scheduleAdvance(item.durationSec * 1000L) scheduleAdvance(item.durationSec * 1000L)
} }
} }
@ -215,64 +185,4 @@ 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

@ -1,80 +0,0 @@
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,8 +2,6 @@ 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
@ -37,24 +35,16 @@ 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>()
// Render context kept for rotation re-renders. private var activeVideoCount = 0
private var renderServerUrl = "" private var completedVideoCount = 0
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) {
@ -78,140 +68,86 @@ 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 ONLY our own zone views/timers. `container` is the activity root and // Clear existing zone views
// also holds the static playerView/imageView/youtubeWebView/statusOverlay - container.removeAllViews()
// 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()
renderServerUrl = serverUrl activeVideoCount = 0
renderCache = contentCache completedVideoCount = 0
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, retry after layout. // Container not laid out yet, post delayed
container.post { renderAssignments(assignments, serverUrl, contentCache) } container.post { renderAssignments(assignments, serverUrl, contentCache) }
return return
} }
// Group assignments by zone_id, ordered by sort_order so rotation is stable. // Map assignments by zone_id
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) } }
// Unassigned content (zone_id=null) goes to the FIRST zone only. // Render each zone - only show content specifically assigned to this zone
// 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()
if (zoneAssignments.isEmpty()) continue val firstAssignment = zoneAssignments.firstOrNull() ?: 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 }
com.remotedisplay.player.util.DebugLog.i("Zone", "Zone '${zone.name}' (${zone.widthPercent.toInt()}x${zone.heightPercent.toInt()}%) -> ${zoneAssignments.size} item(s)") val params = FrameLayout.LayoutParams(w, h).apply {
showZoneItem(zone, zoneAssignments, 0, params) leftMargin = x
} topMargin = y
Log.i(TAG, "Rendered ${zoneViews.size} zone views")
} }
// #74/#75 zone schedule helpers. val mimeType = firstAssignment.optString("mime_type", "")
private fun assignmentAllows(a: JSONObject): Boolean { val remoteUrl = if (firstAssignment.isNull("remote_url")) null else firstAssignment.optString("remote_url", null)
val arr = a.optJSONArray("schedules") ?: return true val widgetType = if (firstAssignment.isNull("widget_type")) null else firstAssignment.optString("widget_type", null)
if (arr.length() == 0) return true val widgetConfig = if (firstAssignment.isNull("widget_config")) null else firstAssignment.optString("widget_config", null)
val blocks = ArrayList<ScheduleEval.Block>(arr.length()) val contentId = if (firstAssignment.isNull("content_id")) null else firstAssignment.optString("content_id", null)
for (j in 0 until arr.length()) { val filepath = firstAssignment.optString("filepath", "")
val s = arr.getJSONObject(j) val isMuted = firstAssignment.optInt("muted", 0) == 1
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 = a.optString("widget_id", "") val widgetId = firstAssignment.optString("widget_id", "")
val webView = createWebView() val webView = createWebView()
webView.loadUrl("$renderServerUrl/api/widgets/$widgetId/render") webView.loadUrl("$serverUrl/api/widgets/$widgetId/render")
webView.layoutParams = params webView.layoutParams = params
container.addView(webView); zoneViews[zone.id] = webView container.addView(webView)
if (multi) scheduleZoneAdvance(zone.id, durationMs, advance) zoneViews[zone.id] = webView
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()
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl) webView.loadUrl(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); zoneViews[zone.id] = webView container.addView(webView)
if (multi) scheduleZoneAdvance(zone.id, durationMs, advance) zoneViews[zone.id] = webView
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) renderCache?.getCachedFile(contentId)?.let { Uri.fromFile(it).toString() } else if (contentId != null) contentCache.getCachedFile(contentId)?.let { Uri.fromFile(it).toString() }
?: "$renderServerUrl/uploads/content/$filepath" ?: "$serverUrl/uploads/content/$filepath"
else { if (multi) scheduleZoneAdvance(zone.id, durationMs, advance); return } else continue
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
@ -219,19 +155,20 @@ 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 = if (multi) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ALL repeatMode = 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); zoneViews[zone.id] = playerView; zoneExoPlayers[zone.id] = exoPlayer container.addView(playerView)
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 {
@ -242,63 +179,44 @@ class ZoneManager(
} }
layoutParams = params layoutParams = params
} }
val targetW = if (params.width > 0) params.width else com.remotedisplay.player.util.ImageLoader.screenWidth(context)
val targetH = if (params.height > 0) params.height else com.remotedisplay.player.util.ImageLoader.screenHeight(context) // Load image
val file = contentId?.let { renderCache?.getCachedFile(it) } val file = contentId?.let { contentCache.getCachedFile(it) }
if (file != null) { if (file != null) {
val bitmap = com.remotedisplay.player.util.ImageLoader.decodeFile(file, targetW, targetH) val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
if (bitmap != null) { if (bitmap != null) imageView.setImageBitmap(bitmap)
try { imageView.setImageBitmap(bitmap) } catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") } } else if (!remoteUrl.isNullOrEmpty()) {
} else { // Load from URL in background
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 {
val bitmap = com.remotedisplay.player.util.ImageLoader.decodeUrl(imgUrl, targetW, targetH) try {
if (bitmap != null) { val connection = java.net.URL(remoteUrl).openConnection()
imageView.post { val input = connection.getInputStream()
try { imageView.setImageBitmap(bitmap) } catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") } val bitmap = android.graphics.BitmapFactory.decodeStream(input)
} input.close()
} else { imageView.post { if (bitmap != null) imageView.setImageBitmap(bitmap) }
Log.w(TAG, "Zone ${zone.name}: unloadable image $contentId via $imgUrl") } catch (e: Exception) {
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) }
} }
} }
private fun scheduleZoneAdvance(zoneId: String, delayMs: Long, advance: () -> Unit) { Log.i(TAG, "Rendered ${zoneViews.size} zone views")
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 {
com.remotedisplay.player.util.WebViewSupport.configure(this, "Zone") settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
setBackgroundColor(android.graphics.Color.TRANSPARENT)
webViewClient = WebViewClient()
} }
} }
@ -308,12 +226,8 @@ class ZoneManager(
} }
fun cleanup() { fun cleanup() {
cancelAllRotations()
releaseExoPlayers() releaseExoPlayers()
// Remove ONLY the views we added for zones; the activity's static views live container.removeAllViews()
// 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,9 +1,15 @@
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) {
@ -13,9 +19,57 @@ 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/
// tap-to-resume). See Relauncher. // Start the foreground service
Relauncher.relaunch(context, Relauncher.BOOT) try {
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

@ -1,101 +0,0 @@
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

@ -1,23 +0,0 @@
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,7 +8,6 @@ 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() {
@ -23,53 +22,7 @@ class PowerAccessibilityService : AccessibilityService() {
Log.i(TAG, "Service connected") Log.i(TAG, "Service connected")
} }
private var lastConfirm = 0L override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
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

@ -1,103 +0,0 @@
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,16 +54,6 @@ object ScreenCaptureService {
imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4) imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4)
// #5: Android 14+ requires a Callback registered BEFORE createVirtualDisplay,
// otherwise createVirtualDisplay throws IllegalStateException. (Was registered
// after, which broke system capture on Android 14+.)
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped by system")
cleanup()
}
}, null)
virtualDisplay = projection.createVirtualDisplay( virtualDisplay = projection.createVirtualDisplay(
"ScreenTinker", "ScreenTinker",
captureWidth, captureHeight, density, captureWidth, captureHeight, density,
@ -71,6 +61,13 @@ object ScreenCaptureService {
imageReader!!.surface, null, null imageReader!!.surface, null, null
) )
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped by system")
cleanup()
}
}, null)
Log.i(TAG, "MediaProjection started: ${captureWidth}x${captureHeight}") Log.i(TAG, "MediaProjection started: ${captureWidth}x${captureHeight}")
} }

View file

@ -5,9 +5,6 @@ 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
@ -20,7 +17,6 @@ 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) {
@ -37,45 +33,8 @@ 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()
@ -148,20 +107,6 @@ 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)
@ -221,15 +166,9 @@ 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").setPackage(context.packageName), Intent("com.remotedisplay.player.INSTALL_COMPLETE"),
android.app.PendingIntent.FLAG_MUTABLE android.app.PendingIntent.FLAG_MUTABLE
) )
session.commit(pendingIntent.intentSender) session.commit(pendingIntent.intentSender)
@ -240,56 +179,6 @@ 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,16 +53,7 @@ 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
@ -74,21 +65,6 @@ 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()) {
@ -103,221 +79,189 @@ class WebSocketService : Service() {
forceNew = true forceNew = true
reconnection = true reconnection = true
reconnectionAttempts = Integer.MAX_VALUE reconnectionAttempts = Integer.MAX_VALUE
// Exponential backoff: starts at 1s, doubles each attempt, capped at 60s, reconnectionDelay = 2000
// ±50% jitter so a fleet doesn't reconnect in lockstep after a server blip. reconnectionDelayMax = 10000
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 {
safeOn(Socket.EVENT_CONNECT) { on(Socket.EVENT_CONNECT) {
Log.i("WebSocketService", "Connected to server") Log.i("WebSocketService", "Connected to server")
register() register()
} }
safeOn(Socket.EVENT_DISCONNECT) { args -> on(Socket.EVENT_DISCONNECT) {
val reason = args.firstOrNull()?.toString() ?: "unknown" Log.w("WebSocketService", "Disconnected from server")
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()
} }
safeOn(Socket.EVENT_CONNECT_ERROR) { args -> on(Socket.EVENT_CONNECT_ERROR) { args ->
Log.e("WebSocketService", "Connection error: ${args.firstOrNull()}") Log.e("WebSocketService", "Connection error: ${args.firstOrNull()}")
} }
safeOn("device:registered") { args -> on("device:registered") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn val data = args[0] as JSONObject
val newDeviceId = data.optString("device_id", "") val newDeviceId = data.getString("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.optString("device_token", "") config.deviceToken = data.getString("device_token")
} }
Log.i("WebSocketService", "Registered as: $newDeviceId") Log.i("WebSocketService", "Registered as: $newDeviceId")
handler.post { try { onRegistered?.invoke(newDeviceId) } catch (e: Throwable) { Log.e("WebSocketService", "onRegistered cb: ${e.message}") } } handler.post { onRegistered?.invoke(newDeviceId) }
startHeartbeat() startHeartbeat()
} }
safeOn("device:unpaired") { on("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 { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } } handler.post { onUnpaired?.invoke() }
} }
safeOn("device:auth-error") { args -> on("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 { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } } handler.post { onUnpaired?.invoke() }
} }
safeOn("device:paired") { args -> on("device:paired") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn val data = args[0] as JSONObject
val id = data.optString("device_id", "") val id = data.getString("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 { try { onPaired?.invoke(id, name) } catch (e: Throwable) { Log.e("WebSocketService", "onPaired cb: ${e.message}") } } handler.post { onPaired?.invoke(id, name) }
} }
safeOn("device:playlist-update") { args -> on("device:playlist-update") { args ->
val data = args.firstOrNull() as? JSONObject ?: run { Log.i("WebSocketService", "Playlist raw args: ${args.size} items, type=${args[0]?.javaClass?.name}, data=${args[0]}")
Log.w("WebSocketService", "playlist-update with non-JSONObject payload: ${args.firstOrNull()}") val data = args[0] as JSONObject
return@safeOn Log.i("WebSocketService", "Playlist update received, keys=${data.keys().asSequence().toList()}, assignments=${data.optJSONArray("assignments")?.length() ?: "null"}")
} 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}") } }
} }
safeOn("device:content-delete") { args -> on("device:content-delete") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn val data = args[0] as JSONObject
val contentId = data.optString("content_id", "") val contentId = data.getString("content_id")
if (contentId.isNotEmpty()) { handler.post { onContentDelete?.invoke(contentId) }
handler.post { try { onContentDelete?.invoke(contentId) } catch (e: Throwable) { Log.e("WebSocketService", "onContentDelete cb: ${e.message}") } }
}
} }
safeOn("device:screenshot-request") { on("device:screenshot-request") {
captureAndSendScreenshot() captureAndSendScreenshot()
handler.post { try { onScreenshotRequest?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onScreenshotRequest cb: ${e.message}") } } handler.post { onScreenshotRequest?.invoke() }
} }
safeOn("device:remote-start") { on("device:remote-start") {
startScreenshotStream() startScreenshotStream()
handler.post { try { onRemoteStart?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteStart cb: ${e.message}") } } handler.post { onRemoteStart?.invoke() }
} }
safeOn("device:remote-stop") { on("device:remote-stop") {
stopScreenshotStream() stopScreenshotStream()
handler.post { try { onRemoteStop?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteStop cb: ${e.message}") } } handler.post { onRemoteStop?.invoke() }
} }
safeOn("device:remote-touch") { args -> on("device:remote-touch") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn val data = args[0] as JSONObject
val x = data.optDouble("x", 0.0).toFloat() val x = data.getDouble("x").toFloat()
val y = data.optDouble("y", 0.0).toFloat() val y = data.getDouble("y").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 { try { svc.injectTap(x, y) } catch (e: Throwable) { Log.e("WebSocketService", "injectTap: ${e.message}") } } handler.post { svc.injectTap(x, y) }
} else { } else {
handler.post { try { onRemoteTouch?.invoke(x, y, action) } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteTouch cb: ${e.message}") } } handler.post { onRemoteTouch?.invoke(x, y, action) }
} }
} }
safeOn("device:remote-key") { args -> on("device:remote-key") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn val data = args[0] as JSONObject
val keycode = data.optString("keycode", "") val keycode = data.getString("keycode")
if (keycode.isEmpty()) return@safeOn // Always inject via shell (works even when app not in foreground)
injectKey(keycode) injectKey(keycode)
handler.post { try { onRemoteKey?.invoke(keycode) } catch (e: Throwable) { Log.e("WebSocketService", "onRemoteKey cb: ${e.message}") } } handler.post { onRemoteKey?.invoke(keycode) }
} }
safeOn("device:command") { args -> on("device:command") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn val data = args[0] as JSONObject
val type = data.optString("type", "") val type = data.getString("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)
} catch (e: Throwable) { Log.e("WebSocketService", "settings cmd: ${e.message}") } Log.i("WebSocketService", "Opened system settings")
} }
} }
"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)
} catch (e: Throwable) { Log.e("WebSocketService", "enable_system_capture: ${e.message}") } Log.i("WebSocketService", "Requesting system capture permission")
} }
} }
"screen_off" -> { "screen_off" -> {
val a11y = PowerAccessibilityService.instance val a11y = PowerAccessibilityService.instance
if (a11y != null) { if (a11y != null) {
handler.post { try { a11y.lockScreen() } catch (e: Throwable) { Log.e("WebSocketService", "lockScreen: ${e.message}") } } handler.post { a11y.lockScreen() }
} 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()
} }
"set_debug" -> { else -> handler.post { onCommand?.invoke(type, payload) }
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: Throwable) { } catch (e: Exception) {
Log.e("WebSocketService", "Socket setup error: ${e.message}", e) Log.e("WebSocketService", "Socket setup error: ${e.message}")
} }
} }
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 = "" config.deviceId = "" // Will be set on registered event
// 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()
} }
try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") } put("device_info", deviceInfo.getDeviceInfo())
try { put("fingerprint", deviceInfo.getFingerprint()) } catch (e: Throwable) { Log.w("WebSocketService", "fingerprint: ${e.message}") } put("fingerprint", deviceInfo.getFingerprint())
} }
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 {
@ -347,17 +291,16 @@ 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")
try { // Re-register triggers the server to send current playlist
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()) put("device_token", token) if (token.isNotEmpty()) {
try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") } put("device_token", token)
}
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() {
@ -367,15 +310,11 @@ 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)
try { put("telemetry", deviceInfo.getTelemetry()) } catch (e: Throwable) { Log.w("WebSocketService", "telemetry: ${e.message}") } put("telemetry", deviceInfo.getTelemetry())
} }
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)
@ -442,13 +381,11 @@ 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) {
@ -503,32 +440,28 @@ 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()
try { socket?.disconnect() } catch (e: Throwable) { Log.w("WebSocketService", "disconnect: ${e.message}") } socket?.disconnect()
try { socket?.off() } catch (e: Throwable) { Log.w("WebSocketService", "off: ${e.message}") } socket?.off()
socket = null socket = null
} }

View file

@ -30,9 +30,6 @@ 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

@ -1,25 +0,0 @@
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

@ -1,94 +0,0 @@
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

@ -1,85 +0,0 @@
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,22 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Scrolls so content is always reachable on short screens; the pairing code <LinearLayout
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:fillViewport="true" android:gravity="center"
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:paddingHorizontal="32dp" android:background="#111827"
android:paddingTop="24dp" android:padding="48dp"
android:paddingBottom="24dp"
android:keepScreenOn="true"> android:keepScreenOn="true">
<TextView <TextView
@ -24,21 +14,20 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="RemoteDisplay" android:text="RemoteDisplay"
android:textColor="#3B82F6" android:textColor="#3B82F6"
android:textSize="22sp" android:textSize="36sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginBottom="4dp" /> android:layout_marginBottom="8dp" />
<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="14sp" android:textSize="16sp"
android:layout_marginBottom="20dp" /> android:layout_marginBottom="48dp" />
<!-- Server URL Section (hidden once paired so the code has room) --> <!-- Server URL Section -->
<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"
@ -88,7 +77,7 @@
<!-- Pairing Section (shown after connection) --> <!-- Pairing Section (shown after connection) -->
<LinearLayout <LinearLayout
android:id="@+id/pairingSection" android:id="@+id/pairingSection"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center" android:gravity="center"
@ -102,28 +91,19 @@
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="match_parent" android:layout_width="wrap_content"
android:layout_height="96dp" android:layout_height="wrap_content"
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="match_parent" android:layout_width="wrap_content"
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"
@ -138,8 +118,6 @@
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,42 +6,31 @@
android:gravity="center" android:gravity="center"
android:orientation="vertical" android:orientation="vertical"
android:background="#111827" android:background="#111827"
android:padding="12dp" android:padding="48dp"
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="12sp" android:textSize="32sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginBottom="3dp" /> android:layout_marginBottom="8dp" />
<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="9sp" android:textSize="16sp"
android:layout_marginBottom="8dp" /> android:layout_marginBottom="40dp" />
<LinearLayout <LinearLayout
android:layout_width="420dp" android:layout_width="500dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:layout_marginBottom="8dp"> android:layout_marginBottom="32dp">
<!-- Accessibility Service --> <!-- Accessibility Service -->
<LinearLayout <LinearLayout
@ -49,7 +38,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="5dp"> android:layout_marginBottom="16dp">
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
@ -62,7 +51,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="11sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
@ -70,7 +59,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="8sp" /> android:textSize="13sp" />
</LinearLayout> </LinearLayout>
<TextView <TextView
@ -79,24 +68,20 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="OFF" android:text="OFF"
android:textColor="#EF4444" android:textColor="#EF4444"
android:textSize="9sp" android:textSize="14sp"
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="wrap_content" android:layout_height="40dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable" android:text="Enable"
android:textColor="#FFFFFF" android:textColor="#FFFFFF"
android:textSize="9sp" android:textSize="14sp"
android:background="@drawable/button_primary" android:background="@drawable/button_primary"
android:paddingStart="12dp" android:paddingStart="20dp"
android:paddingEnd="12dp" android:paddingEnd="20dp" />
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout> </LinearLayout>
<!-- Display Over Other Apps --> <!-- Display Over Other Apps -->
@ -105,7 +90,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="5dp"> android:layout_marginBottom="16dp">
<LinearLayout <LinearLayout
android:layout_width="0dp" android:layout_width="0dp"
@ -118,15 +103,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="11sp" android:textSize="18sp"
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="OTA updates — only our signature-verified builds install" android:text="Required for automatic OTA updates"
android:textColor="#64748B" android:textColor="#64748B"
android:textSize="8sp" /> android:textSize="13sp" />
</LinearLayout> </LinearLayout>
<TextView <TextView
@ -135,24 +120,20 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="OFF" android:text="OFF"
android:textColor="#EF4444" android:textColor="#EF4444"
android:textSize="9sp" android:textSize="14sp"
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="wrap_content" android:layout_height="40dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable" android:text="Enable"
android:textColor="#FFFFFF" android:textColor="#FFFFFF"
android:textSize="9sp" android:textSize="14sp"
android:background="@drawable/button_primary" android:background="@drawable/button_primary"
android:paddingStart="12dp" android:paddingStart="20dp"
android:paddingEnd="12dp" android:paddingEnd="20dp" />
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout> </LinearLayout>
<!-- Notification Permission (Android 13+) --> <!-- Notification Permission (Android 13+) -->
@ -162,7 +143,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="5dp" android:layout_marginBottom="16dp"
android:visibility="gone"> android:visibility="gone">
<LinearLayout <LinearLayout
@ -176,7 +157,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="11sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
@ -184,7 +165,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="8sp" /> android:textSize="13sp" />
</LinearLayout> </LinearLayout>
<TextView <TextView
@ -193,215 +174,33 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="OFF" android:text="OFF"
android:textColor="#EF4444" android:textColor="#EF4444"
android:textSize="9sp" android:textSize="14sp"
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="wrap_content" android:layout_height="40dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable" android:text="Enable"
android:textColor="#FFFFFF" android:textColor="#FFFFFF"
android:textSize="9sp" android:textSize="14sp"
android:background="@drawable/button_primary" android:background="@drawable/button_primary"
android:paddingStart="12dp" android:paddingStart="20dp"
android:paddingEnd="12dp" android:paddingEnd="20dp" />
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="420dp" android:layout_width="500dp"
android:layout_height="wrap_content" android:layout_height="48dp"
android:minHeight="0dp"
android:text="Continue to Setup" android:text="Continue to Setup"
android:textColor="#FFFFFF" android:textColor="#FFFFFF"
android:textSize="11sp" android:textSize="16sp"
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"
@ -409,11 +208,8 @@
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="8sp" android:textSize="13sp"
android:layout_marginTop="6dp" android:layout_marginTop="16dp"
android:padding="8dp" /> android:padding="8dp" />
</LinearLayout>
</ScrollView>
</LinearLayout> </LinearLayout>

View file

@ -1,7 +0,0 @@
<?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

@ -1,7 +0,0 @@
<?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

@ -1,7 +0,0 @@
<?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

@ -1,7 +0,0 @@
<?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

@ -1,7 +0,0 @@
<?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,6 +2,4 @@
<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

@ -1,50 +0,0 @@
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

@ -1,33 +0,0 @@
# 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

@ -1,137 +0,0 @@
# 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` |

View file

@ -1,175 +0,0 @@
# 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

@ -1,615 +0,0 @@
# 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.

File diff suppressed because it is too large Load diff

View file

@ -1,17 +0,0 @@
<!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.

Before

Width:  |  Height:  |  Size: 126 KiB

View file

@ -1,158 +0,0 @@
<!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

@ -1,159 +0,0 @@
<!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

@ -1,155 +0,0 @@
<!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,191 +32,9 @@ 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 {
@ -419,322 +237,6 @@ 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;
} }
@ -1376,12 +878,6 @@ 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;
@ -1389,8 +885,8 @@ body {
top: 12px; top: 12px;
left: 12px; left: 12px;
z-index: 200; z-index: 200;
width: 44px; width: 40px;
height: 44px; height: 40px;
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);
@ -1419,74 +915,14 @@ body {
z-index: 140; z-index: 140;
} }
.sidebar-backdrop.open { display: block; } .sidebar-backdrop.open { display: block; }
.nav-link { min-height: 44px; padding: 10px 14px; } .content { margin-left: 0; padding: 16px; padding-top: 60px; }
.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; } .info-grid { grid-template-columns: 1fr 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 { .tabs { overflow-x: auto; }
overflow-x: auto; .tab { white-space: nowrap; }
-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; }
} }

View file

@ -1,87 +0,0 @@
/* 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

@ -1,157 +0,0 @@
<!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

@ -1,167 +0,0 @@
<!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

@ -1,196 +0,0 @@
<!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" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="sidebar"> <button class="mobile-menu-btn" id="mobileMenuBtn" onclick="document.querySelector('.sidebar').classList.toggle('open');document.getElementById('sidebarBackdrop').classList.toggle('open')">
<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"></div> <div class="sidebar-backdrop" id="sidebarBackdrop" onclick="document.querySelector('.sidebar').classList.remove('open');this.classList.remove('open')"></div>
<nav class="sidebar" id="sidebar"> <nav class="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,11 +29,8 @@
<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 id="brandName">ScreenTinker</span> <span>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">
@ -109,10 +106,7 @@
</svg> </svg>
<span>Activity</span> <span>Activity</span>
</a></li> </a></li>
<!-- Teams nav hidden while the feature is being redesigned as a user-grouping <li><a href="#/teams" class="nav-link" data-view="teams">
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"/>
@ -162,45 +156,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 data-i18n="add_display.title">Add Display</h3> <h3>Add Display</h3>
<button class="btn-icon" data-close-modal="addDeviceModal"> <button class="btn-icon" onclick="document.getElementById('addDeviceModal').style.display='none'">
<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" data-i18n="add_display.intro">Enter the 6-digit pairing code shown on the display.</p> <p class="modal-description" style="margin-bottom:16px">Enter the 6-digit pairing code shown on the display.</p>
<div class="form-group"> <div class="form-group">
<label data-i18n="add_display.pairing_code">Pairing Code</label> <label>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 data-i18n="add_display.display_name">Display Name (optional)</label> <label>Display Name (optional)</label>
<input type="text" id="deviceNameInput" data-i18n-placeholder="add_display.name_placeholder" placeholder="e.g., Lobby TV" class="input"> <input type="text" id="deviceNameInput" 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" data-i18n="add_display.need_player">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">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; <span data-i18n="add_display.android_apk">Android APK</span> &#129302; Android APK
</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; <span data-i18n="add_display.web_player">Web Player</span> &#127760; Web Player
</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; <span data-i18n="add_display.raspberry_pi">Raspberry Pi</span> &#129359; Raspberry Pi
</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; <span data-i18n="add_display.windows">Windows</span> &#128187; Windows
</a> </a>
</div> </div>
<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> <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>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" data-close-modal="addDeviceModal" data-i18n="common.cancel">Cancel</button> <button class="btn btn-secondary" onclick="document.getElementById('addDeviceModal').style.display='none'">Cancel</button>
<button class="btn btn-primary" id="pairDeviceBtn" data-i18n="add_display.pair_btn">Pair Display</button> <button class="btn btn-primary" id="pairDeviceBtn">Pair Display</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -39,34 +39,9 @@ export const api = {
}), }),
// Content // Content
getContent: (folderId) => { getContent: () => request('/content'),
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);
@ -128,13 +103,6 @@ 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 }) }),
@ -146,71 +114,14 @@ 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,192 +16,13 @@ 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');
} }
@ -212,69 +33,12 @@ 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';
@ -287,69 +51,6 @@ 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';
@ -363,8 +64,6 @@ 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;
@ -373,8 +72,6 @@ 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();
@ -440,17 +137,9 @@ 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);
@ -470,9 +159,9 @@ function updateSidebarUser() {
const user = getCurrentUser(); const user = getCurrentUser();
if (!user) return; if (!user) return;
// Show admin nav only for platform admins (legacy 'superadmin' or Phase 1 renamed 'platform_admin') // Show admin nav only for superadmins
const adminNav = document.getElementById('adminNavItem'); const adminNav = document.getElementById('adminNavItem');
if (adminNav) adminNav.style.display = isPlatformAdmin(user) ? '' : 'none'; if (adminNav) adminNav.style.display = user.role === 'superadmin' ? '' : 'none';
let userEl = document.getElementById('sidebarUser'); let userEl = document.getElementById('sidebarUser');
if (!userEl) { if (!userEl) {
@ -490,7 +179,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="${t('auth.sign_out')}" style="flex-shrink:0"> <button id="logoutBtn" class="btn-icon" title="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"/>
@ -508,47 +197,19 @@ 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(() => {});
} }
// Mobile sidebar: open/close via hamburger, backdrop, nav tap, Escape // Close mobile menu on navigation
const sidebarEl = document.querySelector('.sidebar'); window.addEventListener('hashchange', () => {
const backdropEl = document.getElementById('sidebarBackdrop'); document.querySelector('.sidebar')?.classList.remove('open');
const menuBtn = document.getElementById('mobileMenuBtn'); document.getElementById('sidebarBackdrop')?.classList.remove('open');
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)
@ -601,12 +262,3 @@ 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

@ -1,54 +0,0 @@
// 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 */ }
})();

View file

@ -1,71 +0,0 @@
// 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

@ -1,72 +0,0 @@
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

@ -1,163 +0,0 @@
// "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,8 +2,6 @@ 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

@ -1,75 +0,0 @@
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

@ -1,193 +0,0 @@
// 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

@ -1,124 +0,0 @@
// 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

@ -1,82 +0,0 @@
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

@ -1,244 +0,0 @@
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,61 +1,147 @@
// Lightweight i18n loader. Each language is its own file under ./i18n/ so a const translations = {
// translator can edit one file without touching the others. English is the en: {
// canonical source — every other locale falls back to en for any missing key. // Nav
import en from './i18n/en.js'; 'nav.displays': 'Displays',
import es from './i18n/es.js'; 'nav.content': 'Content',
import fr from './i18n/fr.js'; 'nav.layouts': 'Layouts',
import de from './i18n/de.js'; 'nav.widgets': 'Widgets',
import pt from './i18n/pt.js'; 'nav.schedule': 'Schedule',
import hi from './i18n/hi.js'; 'nav.walls': 'Video Walls',
import it from './i18n/it.js'; 'nav.reports': 'Reports',
'nav.designer': 'Designer',
const fallback = en; 'nav.activity': 'Activity',
const registry = { en, es, fr, de, pt, hi, it }; 'nav.settings': 'Settings',
'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 (!registry[currentLang]) currentLang = 'en'; if (!translations[currentLang]) currentLang = 'en';
function lookup(key) { export function t(key) {
return registry[currentLang]?.[key] ?? fallback[key] ?? key; return translations[currentLang]?.[key] || translations.en[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() {
@ -67,15 +153,6 @@ 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);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,18 +0,0 @@
// 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 {};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,11 +4,7 @@ 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', () => {
@ -52,22 +48,6 @@ 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);
@ -129,20 +109,8 @@ 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 });
} }
// Optional callback receives the server-side ack: { delivered, queued, reason }. export function sendCommand(deviceId, type, payload) {
// Callers without a callback keep firing-and-forgetting (no behavior change). if (dashboardSocket) dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload });
// 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,11 +3,3 @@ 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,17 +1,16 @@
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>${t('activity.title')}</h1><div class="subtitle">${t('activity.subtitle')}</div></div> <div><h1>Activity Log</h1><div class="subtitle">Audit trail of all actions</div></div>
</div> </div>
<div id="activityList"><div class="empty-state"><h3>${t('common.loading')}</h3></div></div> <div id="activityList"><div class="empty-state"><h3>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">${t('activity.load_more')}</button> <button class="btn btn-secondary btn-sm" id="loadMoreBtn" style="display:none">Load More</button>
</div> </div>
`; `;
@ -26,14 +25,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>${t('activity.empty_title')}</h3><p>${t('activity.empty_desc')}</p></div>`; list.innerHTML = '<div class="empty-state"><h3>No activity yet</h3><p>Actions will appear here as you use the system.</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(undefined, { month: 'short', day: 'numeric' }) + ' ' + const timeStr = time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
time.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const icon = getActionIcon(item.action); const icon = getActionIcon(item.action);
return ` return `
@ -41,7 +40,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 || t('activity.system'))}</strong> <strong>${esc(item.user_name || item.user_email || '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>` : ''}
@ -82,29 +81,22 @@ 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) {
// Verbs return action
let s = action .replace('POST /api/', 'created ')
.replace('POST /api/', t('activity.verb_created') + ' ') .replace('PUT /api/', 'updated ')
.replace('PUT /api/', t('activity.verb_updated') + ' ') .replace('DELETE /api/', 'deleted ')
.replace('DELETE /api/', t('activity.verb_deleted') + ' '); .replace('/provision/pair', 'paired a device')
// Specific endpoints .replace('/content/remote', 'added remote content')
s = s .replace('/content', 'content')
.replace('/provision/pair', t('activity.action_paired_device')) .replace('/devices/:id', 'device')
.replace('/content/remote', t('activity.action_added_remote_content')) .replace('/assignments/device/:deviceId', 'playlist assignment')
.replace('/content', t('activity.noun_content')) .replace('/assignments/:id', 'assignment')
.replace('/devices/:id', t('activity.noun_device')) .replace('/layouts', 'layout')
.replace('/assignments/device/:deviceId', t('activity.noun_playlist_assignment')) .replace('/widgets', 'widget')
.replace('/assignments/:id', t('activity.noun_assignment')) .replace('/schedules', 'schedule')
.replace('/layouts', t('activity.noun_layout')) .replace('/walls', 'video wall')
.replace('/widgets', t('activity.noun_widget')) .replace('alert:device_offline', 'alert: device went offline');
.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

@ -1,336 +0,0 @@
// 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,341 +1,121 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { esc, isPlatformAdmin } from '../utils.js'; import { esc } 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 (!isPlatformAdmin(user)) { if (user.role !== 'superadmin') {
container.innerHTML = `<div class="empty-state"><h3>${t('admin.access_denied')}</h3><p>${t('admin.access_denied_desc')}</p></div>`; container.innerHTML = '<div class="empty-state"><h3>Access Denied</h3><p>Platform admin access required.</p></div>';
return; return;
} }
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div><h1>${t('admin.title')}</h1><div class="subtitle">${t('admin.subtitle')}</div></div> <div><h1>Platform Admin</h1><div class="subtitle">Superadmin controls - only you can see this</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>${t('admin.all_users')}</h3> <h3>All Users</h3>
<div id="allUsersTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div> <div id="allUsersTable"><p style="color:var(--text-muted)">Loading...</p></div>
</div> </div>
<!-- Plan Management -->
<div class="settings-section"> <div class="settings-section">
<h3>${t('admin.orgs.title')}</h3> <h3>Subscription Plans</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.orgs.desc')}</p> <div id="plansTable"><p style="color:var(--text-muted)">Loading...</p></div>
<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>${t('admin.branding.title')}</h3> <h3>System</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.branding.desc')}</p> <div id="systemInfo"><p style="color:var(--text-muted)">Loading...</p></div>
<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([ const [users, plans] = await Promise.all([API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json())]);
API('/auth/users'),
fetch('/api/subscription/plans').then(r => r.json()),
]);
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
el.innerHTML = ` el.innerHTML = `
<div class="table-wrap"> <table style="width:100%;border-collapse:collapse;font-size:13px">
<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)">${t('admin.col.user')}</th> <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.auth')}</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.last_login')}</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.role')}</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.plan')}</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.workspace')}</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.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() : t('common.never')}</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"> <td style="padding:8px">
<select class="input" style="max-width:120px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}"> <select class="input" style="width:120px;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}">
${PLATFORM_ROLE_OPTIONS.map(r => `<option value="${r}" ${u.role === r ? 'selected' : ''}>${t('admin.role.' + r)}</option>`).join('')} <option value="user" ${u.role === 'user' ? 'selected' : ''}>User</option>
<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="max-width:130px;width:100%;background:var(--bg-input);font-size:12px;padding:4px" data-plan-user="${u.id}"> <select class="input" style="width:130px;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>
${workspaceCell(u)} <td style="padding:8px">
<td style="padding:8px;white-space:nowrap"> ${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>'}
${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>
</div> <p style="color:var(--text-muted);font-size:11px;margin-top:8px">${users.length} total users</p>
<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(t('admin.toast.role_updated'), 'success'); showToast('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(t('admin.toast.plan_updated'), 'success'); showToast('Plan updated', 'success');
} catch (err) { showToast(err.message, 'error'); loadUsers(); } } catch (err) { showToast(err.message, 'error'); loadUsers(); }
}; };
}); });
// Manage workspaces: open the per-user membership modal (add/remove // Delete user
// 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(t('admin.toast.user_removed'), 'success'); loadUsers(); } try { await api.deleteUser(btn.dataset.deleteUser); showToast('User removed', 'success'); loadUsers(); }
catch (err) { showToast(err.message, 'error'); } catch (err) { showToast(err.message, 'error'); }
return; return;
} }
confirming = true; btn.textContent = t('admin.confirm'); btn.style.background = 'var(--danger)'; btn.style.color = 'white'; confirming = true; btn.textContent = 'Confirm?'; btn.style.background = 'var(--danger)'; btn.style.color = 'white';
setTimeout(() => { confirming = false; btn.textContent = t('admin.remove'); btn.style.background = ''; btn.style.color = ''; }, 3000); setTimeout(() => { confirming = false; btn.textContent = '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>`; }
@ -346,28 +126,26 @@ 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 = `
<div class="table-wrap"> <table style="width:100%;border-collapse:collapse;font-size:13px">
<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)">${t('admin.col.plan')}</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">Plan</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)">Devices</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)">Storage</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)">Monthly</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('admin.col.yearly')}</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">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 ? t('admin.unlimited') : p.max_devices}</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_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.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.price_monthly > 0 ? '$'+p.price_monthly : t('admin.free')}</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_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>`; }
} }
@ -379,12 +157,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">${t('admin.version')}</div><div class="info-card-value small">${version.version}</div></div> <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.frontend_hash')}</div><div class="info-card-value small">${version.hash}</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> </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">${t('admin.download_db_backup')}</a> <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" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.server_status')}</a> <a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">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,17 +1,16 @@
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>${t('billing.title')}</h1> <h1>Subscription</h1>
<div class="subtitle">${t('billing.subtitle')}</div> <div class="subtitle">Manage your plan and billing</div>
</div> </div>
</div> </div>
<div id="billingContent"><div class="empty-state"><h3>${t('common.loading')}</h3></div></div> <div id="billingContent"><div class="empty-state"><h3>Loading...</h3></div></div>
`; `;
try { try {
@ -23,26 +22,27 @@ 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>${t('billing.current_plan')}</h3> <h3>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">${t('billing.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">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">${t('billing.trial_days_left', { n: subData.trial.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">Trial - ${subData.trial.days_left} 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">${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: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:12px;color:var(--text-muted)">${t('billing.trial_after')}</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> </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">${t('billing.devices')}</div> <div class="info-card-label">Devices</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> <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>
${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">${t('billing.storage')}</div> <div class="info-card-label">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 ? t('billing.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 ? '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,50 +59,51 @@ export async function render(container) {
</div>` : ''} </div>` : ''}
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-card-label">${t('billing.features')}</div> <div class="info-card-label">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; ${t('billing.feat.remote_control')}</div>` : `<div style="color:var(--text-muted)">&#10007; ${t('billing.feat.remote_control')}</div>`} ${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_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.remote_url ? '<div style="color:var(--success)">&#10003; Remote URLs</div>' : '<div style="color:var(--text-muted)">&#10007; Remote URLs</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>`} ${subData.plan.priority_support ? '<div style="color:var(--success)">&#10003; Priority Support</div>' : '<div style="color:var(--text-muted)">&#10007; Priority Support</div>'}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Plans -->
<div class="settings-section"> <div class="settings-section">
<h3>${t('billing.available_plans')}</h3> <h3>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">${t('billing.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">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">${t('billing.per_month')}</span>` : t('billing.free')} ${p.price_monthly > 0 ? `$${p.price_monthly}<span style="font-size:13px;color:var(--text-secondary);font-weight:400">/mo</span>` : '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 ? t('billing.unlimited') : p.max_devices} ${t('billing.devices_lc')}</div> <div>${p.max_devices === -1 ? 'Unlimited' : p.max_devices} devices</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.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.remote_control ? '&#10003;' : '&#10007;'} ${t('billing.feat.remote_control')}</div> <div>${p.remote_control ? '&#10003;' : '&#10007;'} Remote Control</div>
<div>${p.remote_url ? '&#10003;' : '&#10007;'} ${t('billing.feat.remote_urls')}</div> <div>${p.remote_url ? '&#10003;' : '&#10007;'} Remote URLs</div>
<div>${p.priority_support ? '&#10003;' : '&#10007;'} ${t('billing.feat.priority_support')}</div> <div>${p.priority_support ? '&#10003;' : '&#10007;'} Priority Support</div>
</div> </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>` : ''} ${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>` : ''}
${!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')">${t('billing.monthly')}</button> <button class="btn btn-primary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','monthly')">Monthly</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>` : ''} ${p.price_yearly > 0 ? `<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','yearly')">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()">${t('billing.manage_subscription')}</button> <button class="btn btn-secondary btn-sm" style="width:100%;margin-top:12px" onclick="window._manageSubscription()">Manage Subscription</button>
` : ''} ` : ''}
</div> </div>
`).join('')} `).join('')}
</div> </div>
${subData.self_hosted ? `<p style="color:var(--text-muted);font-size:12px;margin-top:12px">${t('billing.self_hosted_note')}</p>` : ''} ${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>' : ''}
</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', {
@ -114,10 +115,11 @@ 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(t('billing.toast.checkout_failed', { error: err.message }), 'error'); showToast('Failed to start checkout: ' + 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', {
@ -128,17 +130,18 @@ 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(t('billing.toast.portal_failed', { error: err.message }), 'error'); showToast('Failed to open billing portal: ' + 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(t('billing.toast.payment_success'), 'success'); showToast('Payment successful! Your plan has been upgraded.', 'success');
window.location.hash = '#/billing'; window.location.hash = '#/billing';
} }
} catch (err) { } catch (err) {
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>${t('billing.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`; document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>Failed to load</h3><p>${esc(err.message)}</p></div>`;
} }
} }

View file

@ -1,7 +1,6 @@
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 '--';
@ -11,60 +10,30 @@ 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>${t('content.title')} <span class="help-tip" data-tip="${t('content.help_tip')}">?</span></h1> <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>
<div class="subtitle">${t('content.subtitle')}</div> <div class="subtitle">Upload and manage your media files</div>
</div> </div>
</div> </div>
<div class="content-toolbar" style="display:flex;gap:16px;margin-bottom:24px"> <div 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>${t('content.drop')}</p> <p>Drop files here or click to upload</p>
<p class="upload-hint">${t('content.upload_hint')}</p> <p class="upload-hint">Supports MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP</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">${t('content.upload_progress')}</p> <p style="font-size:12px;color:var(--text-secondary);margin-top:6px" id="uploadProgressText">Uploading...</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">
@ -73,18 +42,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>
${t('content.remote_url')} Remote URL
</div> </div>
<p style="font-size:12px;color:var(--text-muted)">${t('content.remote_desc')}</p> <p style="font-size:12px;color:var(--text-muted)">Stream directly from a URL. Saves local bandwidth.</p>
<input type="text" id="remoteUrlInput" class="input" placeholder="${t('content.remote_url_placeholder')}"> <input type="text" id="remoteUrlInput" class="input" placeholder="https://example.com/video.mp4">
<input type="text" id="remoteNameInput" class="input" placeholder="${t('content.remote_name_placeholder')}"> <input type="text" id="remoteNameInput" class="input" placeholder="Display name (optional)">
<select id="remoteMimeType" class="input" style="background:var(--bg-input)"> <select id="remoteMimeType" class="input" style="background:var(--bg-input)">
<option value="video/mp4">${t('content.mime.video_mp4')}</option> <option value="video/mp4">Video (MP4)</option>
<option value="video/webm">${t('content.mime.video_webm')}</option> <option value="video/webm">Video (WebM)</option>
<option value="image/jpeg">${t('content.mime.image_jpeg')}</option> <option value="image/jpeg">Image (JPEG)</option>
<option value="image/png">${t('content.mime.image_png')}</option> <option value="image/png">Image (PNG)</option>
</select> </select>
<button class="btn btn-primary" id="addRemoteBtn">${t('content.remote_add_btn')}</button> <button class="btn btn-primary" id="addRemoteBtn">Add Remote URL</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">
@ -92,24 +61,25 @@ 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>
${t('content.youtube')} YouTube
</div> </div>
<p style="font-size:12px;color:var(--text-muted)">${t('content.youtube_desc')}</p> <p style="font-size:12px;color:var(--text-muted)">Embed a YouTube video on your displays.</p>
<input type="text" id="youtubeUrlInput" class="input" placeholder="${t('content.youtube_url_placeholder')}"> <input type="text" id="youtubeUrlInput" class="input" placeholder="https://youtube.com/watch?v=...">
<input type="text" id="youtubeNameInput" class="input" placeholder="${t('content.youtube_name_placeholder')}"> <input type="text" id="youtubeNameInput" class="input" placeholder="Display name (optional)">
<button class="btn btn-primary" id="addYoutubeBtn">${t('content.youtube_add_btn')}</button> <button class="btn btn-primary" id="addYoutubeBtn">Add YouTube Video</button>
</div> </div>
</div> </div>
</div> </div>
<div style="display:flex;gap:12px;margin-bottom:12px;align-items:center;flex-wrap:wrap"> <div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<input type="text" id="contentSearch" class="input" placeholder="${t('content.search_placeholder')}" style="max-width:250px;width:100%"> <input type="text" id="contentSearch" class="input" placeholder="Search content..." style="width:250px">
<button class="btn btn-secondary btn-sm" id="newFolderBtn">${t('content.new_folder_btn')}</button> <select id="folderFilter" class="input" style="width:180px;background:var(--bg-input)">
<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>${t('common.loading')}</h3></div> <div class="empty-state" style="grid-column:1/-1"><h3>Loading...</h3></div>
</div> </div>
`; `;
@ -145,12 +115,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(t('content.error_enter_url'), 'error'); showToast('Enter a URL', 'error');
return; return;
} }
try { try {
await api.addRemoteContent(url, name, mimeType); await api.addRemoteContent(url, name, mimeType);
showToast(t('content.toast.remote_added'), 'success'); showToast('Remote content added', 'success');
document.getElementById('remoteUrlInput').value = ''; document.getElementById('remoteUrlInput').value = '';
document.getElementById('remoteNameInput').value = ''; document.getElementById('remoteNameInput').value = '';
loadContent(); loadContent();
@ -164,12 +134,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(t('content.error_enter_youtube_url'), 'error'); showToast('Enter a YouTube URL', 'error');
return; return;
} }
try { try {
await api.addYoutubeContent(url, name); await api.addYoutubeContent(url, name);
showToast(t('content.toast.youtube_added'), 'success'); showToast('YouTube video added', 'success');
document.getElementById('youtubeUrlInput').value = ''; document.getElementById('youtubeUrlInput').value = '';
document.getElementById('youtubeNameInput').value = ''; document.getElementById('youtubeNameInput').value = '';
loadContent(); loadContent();
@ -178,41 +148,36 @@ export function render(container) {
} }
}); });
// Content search filters items currently shown in the grid. // Content search + folder filter
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() || '';
item.style.display = (!q || name.includes(q)) ? '' : 'none'; const itemFolder = item.dataset.folder || '';
}); const matchSearch = !q || name.includes(q);
document.querySelectorAll('.folder-card').forEach(card => { const matchFolder = !folder || itemFolder === folder;
const name = card.dataset.name?.toLowerCase() || ''; item.style.display = (matchSearch && matchFolder) ? '' : 'none';
card.style.display = (!q || name.includes(q)) ? '' : 'none';
}); });
} }
document.getElementById('contentSearch').oninput = filterContent; document.getElementById('contentSearch').oninput = filterContent;
document.getElementById('folderFilter').onchange = filterContent;
// Create folder in the current folder. // New folder
document.getElementById('newFolderBtn').onclick = async () => { document.getElementById('newFolderBtn').onclick = () => {
const name = prompt(t('content.prompt_folder_name')); const name = prompt('Folder name:');
if (!name || !name.trim()) return; if (name) {
try { // Just add to the dropdown - folders are created when content is moved into them
await api.createFolder(name.trim(), state.currentFolderId); const opt = document.createElement('option');
showToast(t('content.toast.folder_created_named', { name }), 'success'); opt.value = name; opt.textContent = name;
loadContent(); document.getElementById('folderFilter').appendChild(opt);
} catch (err) { showToast(err.message, 'error'); } showToast(`Folder "${name}" created. Edit content to move it here.`, 'info');
}
}; };
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');
@ -221,16 +186,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 = t('content.upload_progress_named', { name: file.name }); progressText.textContent = `Uploading ${file.name}...`;
try { try {
await api.uploadContent(file, (pct) => { await api.uploadContent(file, (pct) => {
progressFill.style.width = pct + '%'; progressFill.style.width = pct + '%';
progressText.textContent = t('content.upload_progress_named_pct', { name: file.name, pct }); progressText.textContent = `Uploading ${file.name}... ${pct}%`;
}); });
showToast(t('content.toast.uploaded_named', { name: file.name }), 'success'); showToast(`${file.name} uploaded successfully`, 'success');
} catch (err) { } catch (err) {
showToast(t('content.toast.upload_failed_named', { name: file.name, error: err.message }), 'error'); showToast(`Failed to upload ${file.name}: ${err.message}`, 'error');
} }
} }
@ -240,149 +205,30 @@ async function handleFiles(files) {
async function loadContent() { async function loadContent() {
const grid = document.getElementById('contentGrid'); const grid = document.getElementById('contentGrid');
const folderGrid = document.getElementById('folderGrid'); if (!grid) return;
const breadcrumb = document.getElementById('folderBreadcrumb');
if (!grid || !folderGrid || !breadcrumb) return;
try { try {
const [content, folders] = await Promise.all([ const content = await api.getContent();
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 = subfolders.length ? '' : ` grid.innerHTML = `
<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>${state.currentFolderId ? t('content.empty_folder_title') : t('content.no_content')}</h3> <h3>No content yet</h3>
<p>${state.currentFolderId ? t('content.empty_folder_desc') : t('content.no_content_desc')}</p> <p>Upload videos and images to get started.</p>
</div> </div>
`; `;
return; return;
} }
grid.innerHTML = content.map(c => ` grid.innerHTML = content.map(c => `
<div class="content-item" draggable="true" data-content-id="${c.id}" data-folder="${c.folder || ''}"> <div class="content-item" 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="${esc(c.filename)}" loading="lazy" style="width:100%;height:100%;object-fit:cover"> <img src="${c.thumbnail_path}" alt="${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"/>
@ -396,54 +242,56 @@ 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)">${t('content.type_remote_short')}</span> <span style="font-size:10px;color:var(--text-muted)">Remote</span>
</div>` </div>`
: c.thumbnail_path : c.thumbnail_path
? `<img data-auth-src="/api/content/${c.id}/thumbnail" alt="${esc(c.filename)}" style="background:var(--bg-secondary)">` ? `<img src="/api/content/${c.id}/thumbnail" alt="${c.filename}" loading="lazy">`
: 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 data-auth-src="/api/content/${c.id}/file" alt="${esc(c.filename)}" style="background:var(--bg-secondary)">` : `<img src="/api/content/${c.id}/file" alt="${c.filename}" loading="lazy">`
} }
</div> </div>
<div class="content-item-body"> <div class="content-item-body">
<div class="content-item-name" title="${esc(c.filename)}">${esc(c.filename)}</div> <div class="content-item-name" title="${c.filename}">${c.filename}</div>
<div class="content-item-size"> <div class="content-item-size">
${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.mime_type === 'video/youtube' ? 'YouTube' : c.remote_url ? 'Remote URL' : (c.mime_type?.startsWith('video/') ? 'Video' : '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="${t('content.btn_edit')}"> <button class="btn btn-secondary btn-sm" data-edit-content="${c.id}" title="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>
${t('content.btn_edit')} Edit
</button> </button>
<button class="btn btn-danger btn-sm" data-delete-content="${c.id}" title="${t('content.btn_delete')}"> <button class="btn btn-danger btn-sm" data-delete-content="${c.id}" title="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>
${t('content.btn_delete')} Delete
</button> </button>
</div> </div>
</div> </div>
`).join(''); `).join('');
hydrateAuthImages(grid);
// Drag-to-move: each content item exposes its id; folder cards are the drop targets. // Populate folder dropdown
grid.querySelectorAll('.content-item').forEach(item => { const folderSelect = document.getElementById('folderFilter');
item.addEventListener('dragstart', (e) => { const folders = [...new Set(content.filter(c => c.folder).map(c => c.folder))].sort();
e.dataTransfer.setData('text/content-id', item.dataset.contentId); folders.forEach(f => {
e.dataTransfer.effectAllowed = 'move'; if (!folderSelect.querySelector(`option[value="${f}"]`)) {
}); 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
@ -478,14 +326,14 @@ async function loadContent() {
if (btn.dataset.confirming === 'true') { if (btn.dataset.confirming === 'true') {
try { try {
btn.disabled = true; btn.disabled = true;
btn.textContent = t('content.btn_deleting'); btn.textContent = 'Deleting...';
await api.deleteContent(id); await api.deleteContent(id);
showToast(t('content.toast.deleted'), 'success'); showToast('Content deleted', 'success');
loadContent(); loadContent();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
btn.disabled = false; btn.disabled = false;
btn.textContent = t('content.btn_delete'); btn.textContent = 'Delete';
btn.dataset.confirming = 'false'; btn.dataset.confirming = 'false';
} }
return; return;
@ -493,14 +341,14 @@ async function loadContent() {
// First click - show confirm state // First click - show confirm state
btn.dataset.confirming = 'true'; btn.dataset.confirming = 'true';
btn.innerHTML = t('content.btn_confirm_delete'); btn.innerHTML = '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> ${t('content.btn_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> Delete`;
btn.style.background = ''; btn.style.background = '';
btn.style.color = ''; btn.style.color = '';
} }
@ -508,7 +356,7 @@ async function loadContent() {
}; };
} catch (err) { } catch (err) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('content.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`; grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Failed to load content</h3><p>${esc(err.message)}</p></div>`;
} }
} }
@ -520,53 +368,46 @@ function showEditModal(contentItem, onSave) {
const isRemote = !!contentItem.remote_url; const isRemote = !!contentItem.remote_url;
overlay.innerHTML = ` overlay.innerHTML = `
<div class="modal" style="max-width:500px;width:95vw"> <div class="modal" style="width:500px">
<div class="modal-header"> <div class="modal-header">
<h3>${t('content.edit_modal_title')}</h3> <h3>Edit Content</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>${t('content.label_filename')}</label> <label>Filename / Display Name</label>
<input type="text" id="editFilename" class="input" value="${esc(contentItem.filename)}"> <input type="text" id="editFilename" class="input" value="${contentItem.filename}">
</div> </div>
${isRemote ? ` ${isRemote ? `
<div class="form-group"> <div class="form-group">
<label>${t('content.label_remote_url_field')}</label> <label>Remote URL</label>
<input type="text" id="editRemoteUrl" class="input" value="${esc(contentItem.remote_url)}"> <input type="text" id="editRemoteUrl" class="input" value="${contentItem.remote_url}">
</div> </div>
` : ''} ` : ''}
<div class="form-group"> <div class="form-group">
<label>${t('content.label_mime_type')}</label> <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' : ''}>${t('content.mime.video_mp4')}</option> <option value="video/mp4" ${contentItem.mime_type === 'video/mp4' ? 'selected' : ''}>Video (MP4)</option>
<option value="video/webm" ${contentItem.mime_type === 'video/webm' ? 'selected' : ''}>${t('content.mime.video_webm')}</option> <option value="video/webm" ${contentItem.mime_type === 'video/webm' ? 'selected' : ''}>Video (WebM)</option>
<option value="image/jpeg" ${contentItem.mime_type === 'image/jpeg' ? 'selected' : ''}>${t('content.mime.image_jpeg')}</option> <option value="image/jpeg" ${contentItem.mime_type === 'image/jpeg' ? 'selected' : ''}>Image (JPEG)</option>
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>${t('content.mime.image_png')}</option> <option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>Image (PNG)</option>
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>${t('content.mime.image_gif')}</option> <option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>Image (GIF)</option>
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>${t('content.mime.image_webp')}</option> <option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>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>${t('content.label_replace_file')}</label> <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">${t('content.replace_file_hint')}</p> <p style="font-size:11px;color:var(--text-muted);margin-top:4px">Leave empty to keep current file</p>
</div> </div>
` : ''} ` : ''}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" id="cancelEditBtn">${t('common.cancel')}</button> <button class="btn btn-secondary" id="cancelEditBtn">Cancel</button>
<button class="btn btn-primary" id="saveEditBtn">${t('content.save_changes')}</button> <button class="btn btn-primary" id="saveEditBtn">Save Changes</button>
</div> </div>
</div> </div>
`; `;
@ -587,12 +428,10 @@ 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, {
@ -614,10 +453,10 @@ function showEditModal(contentItem, onSave) {
} }
overlay.remove(); overlay.remove();
showToast(t('content.toast.updated'), 'success'); showToast('Content updated', 'success');
if (onSave) onSave(); if (onSave) onSave();
} catch (err) { } catch (err) {
showToast(err.message || t('content.error_update_failed'), 'error'); showToast(err.message || 'Update failed', 'error');
} }
}; };
} }
@ -637,13 +476,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="${esc(src)}" controls autoplay style="max-width:80vw;max-height:80vh;display:block"></video>` ? `<video src="${src}" controls autoplay style="max-width:80vw;max-height:80vh;display:block"></video>`
: `<img src="${esc(src)}" style="max-width:80vw;max-height:80vh;display:block">` : `<img src="${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">${esc(content.filename)}</div> <div style="font-weight:500">${content.filename}</div>
<div style="font-size:12px;color:var(--text-muted)">${esc(content.mime_type)} ${content.remote_url ? `(${t('content.type_remote')})` : ''}</div> <div style="font-size:12px;color:var(--text-muted)">${content.mime_type} ${content.remote_url ? '(Remote URL)' : ''}</div>
</div> </div>
</div> </div>
`; `;
@ -652,17 +491,4 @@ 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,46 +2,28 @@ 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' }, { type: 'screen_on', label: 'Screen On' },
{ type: 'screen_off' }, { type: 'screen_off', label: 'Screen Off' },
{ type: 'launch' }, { type: 'launch', label: 'Restart App' },
{ type: 'update' }, { type: 'update', label: 'Check Update' },
{ type: 'reboot', destructive: true }, { type: 'reboot', label: 'Reboot', destructive: true },
{ type: 'shutdown', destructive: true }, { type: 'shutdown', label: '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 t('common.never'); if (!timestamp) return 'Never';
const seconds = Math.floor(Date.now() / 1000 - timestamp); const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return t('common.just_now'); if (seconds < 60) return 'Just now';
if (seconds < 3600) return t('common.minutes_ago', { n: Math.floor(seconds / 60) }); if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return t('common.hours_ago', { n: Math.floor(seconds / 3600) }); if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return t('common.days_ago', { n: Math.floor(seconds / 86400) }); return `${Math.floor(seconds / 86400)}d ago`;
} }
function formatBytes(mb) { function formatBytes(mb) {
@ -50,44 +32,14 @@ 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${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" onclick="window.location.hash='/device/${device.id}'"> <div class="device-card" data-device-id="${device.id}" 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">`
@ -97,21 +49,17 @@ 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>${t('dashboard.no_preview')}</span> <span>No preview available</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' ? t('dashboard.awaiting_pairing') : device.status}</span> <span>${device.status === 'provisioning' ? '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>
@ -157,77 +105,28 @@ function renderDeviceCard(device) {
`; `;
} }
function renderWallCard(wall) { function renderGroupSection(group, devices) {
// 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">${tn('dashboard.devices_count', devices.length)} &middot; ${t('dashboard.online_count', { n: onlineCount })}</span> <span style="color:var(--text-muted);font-size:12px">${devices.length} device${devices.length !== 1 ? 's' : ''} &middot; ${onlineCount} online</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="">${t('dashboard.send_command_placeholder')}</option> <option value="">Send Command...</option>
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${t(CMD_LABEL_KEY[c.type])}</option>`).join('')} ${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${c.label}</option>`).join('')}
</select> </select>
` : ''} ` : ''}
<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-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="Add/remove devices">Manage</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> <button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="Delete group">&#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">${t('dashboard.no_devices_in_group')}</div>`} ${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>'}
</div> </div>
</div> </div>
`; `;
@ -237,36 +136,26 @@ export function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>${t('dashboard.title')} <span class="help-tip" data-tip="${t('dashboard.help_tip')}">?</span></h1> <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>
<div class="subtitle">${t('dashboard.subtitle')}</div> <div class="subtitle">Manage your remote displays</div>
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn" id="createGroupBtn">${t('dashboard.create_group')}</button> <button class="btn" id="createGroupBtn">+ 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>
${t('dashboard.add')} Add Display
</button> </button>
</div> </div>
</div> </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"> <div id="dashStats" style="display:flex;gap:12px;margin-bottom:16px"></div>
<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="${t('dashboard.search')}" style="max-width:300px"> <input type="text" id="deviceSearch" class="input" placeholder="Search displays..." 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="">${t('dashboard.all_status')}</option> <option value="">All Status</option>
<option value="online">${t('dashboard.online')}</option> <option value="online">Online</option>
<option value="offline">${t('dashboard.offline')}</option> <option value="offline">Offline</option>
</select> </select>
</div> </div>
<div id="groupedDevices"></div> <div id="groupedDevices"></div>
@ -302,13 +191,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(t('dashboard.error_pairing_code'), 'error'); showToast('Enter a valid 6-digit 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(t('dashboard.toast.display_paired'), 'success'); showToast('Display paired successfully!', 'success');
loadDashboard(); loadDashboard();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -317,37 +206,15 @@ export function render(container) {
// Create group // Create group
container.querySelector('#createGroupBtn').addEventListener('click', async () => { container.querySelector('#createGroupBtn').addEventListener('click', async () => {
const name = prompt(t('dashboard.prompt_group_name')); const name = prompt('Group name:');
if (!name) return; if (!name) return;
try { try {
await api.createGroup(name); await api.createGroup(name);
showToast(t('dashboard.toast.group_created'), 'success'); showToast('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();
@ -361,6 +228,7 @@ 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');
@ -376,28 +244,10 @@ 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(() => {
@ -413,77 +263,12 @@ 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 [rawDevices, groups, playlists, walls] = await Promise.all([ const [devices, groups] = await Promise.all([api.getDevices(), api.getGroups()]);
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;
@ -493,20 +278,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">${t('dashboard.total_displays')}</div> <div class="info-card-label">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">${t('dashboard.online')}</div> <div class="info-card-label">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">${t('dashboard.offline')}</div> <div class="info-card-label">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">${t('dashboard.awaiting_pairing')}</div> <div class="info-card-label">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>` : ''}
`; `;
@ -520,72 +305,42 @@ 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>${t('dashboard.no_displays')}</h3> <h3>No displays yet</h3>
<p>${t('dashboard.no_displays_desc')}</p> <p>Install the ScreenTinker app on your Apolosign TV and pair it using the button above.</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)
// and exclude any wall members. const fullDevices = devices.filter(d => memberIds.has(d.id));
const fullDevices = dashboardDevices.filter(d => memberIds.has(d.id));
return { ...g, devices: fullDevices, memberIds }; return { ...g, devices: fullDevices, memberIds };
})); }));
// Render each device exactly once: the first group it belongs to wins. // Find ungrouped devices
// memberIds is preserved for the Manage modal so multi-group membership info stays accurate. const allGroupedIds = new Set();
const renderedIds = new Set(); groupsWithDevices.forEach(g => g.memberIds.forEach(id => allGroupedIds.add(id)));
for (const g of groupsWithDevices) { const ungrouped = devices.filter(d => !allGroupedIds.has(d.id));
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 = '';
// Walls render before groups: they're a higher-level construct (multiple
// physical screens acting as one logical display).
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 each group with its devices // Render each group with its devices
for (const g of groupsWithDevices) { for (const g of groupsWithDevices) {
html += renderGroupSection(g, g.devices, playlists); html += renderGroupSection(g, g.devices);
} }
// Render ungrouped devices. The wrapper is tagged data-ungrouped="1" so // Render ungrouped devices
// 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 class="ungrouped-section" data-ungrouped="1" style="margin-bottom:24px"> <div 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)">${t('dashboard.ungrouped')}</strong> <strong style="font-size:15px;color:var(--text-muted)">Ungrouped</strong>
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${tn('dashboard.devices_count', ungrouped.length)}</span> <span style="color:var(--text-muted);font-size:12px;margin-left:10px">${ungrouped.length} device${ungrouped.length !== 1 ? 's' : ''}</span>
</div>` : ''} </div>` : ''}
<div class="device-grid"> <div class="device-grid">
${ungrouped.map(renderDeviceCard).join('')} ${ungrouped.map(renderDeviceCard).join('')}
@ -595,133 +350,14 @@ async function loadDashboard() {
} }
main.innerHTML = html; main.innerHTML = html;
attachGroupHandlers(groupsWithDevices, dashboardDevices); attachGroupHandlers(groupsWithDevices, devices);
// 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>${t('dashboard.failed_to_load')}</h3><p>${esc(err.message)}</p></div>`; main.innerHTML = `<div class="empty-state"><h3>Failed to load displays</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) => {
@ -730,10 +366,9 @@ 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(t('dashboard.confirm_destructive_command', { cmd: cmdLabel.toUpperCase(), n: count, group: groupName }))) { if (!confirm(`${type.toUpperCase()} all ${count} device${count !== '1' ? 's' : ''} in "${groupName}"?\n\nThis cannot be undone.`)) {
e.target.value = ''; e.target.value = '';
return; return;
} }
@ -741,10 +376,7 @@ function attachGroupHandlers(groupsWithDevices, allDevices) {
try { try {
const result = await api.sendGroupCommand(groupId, type); const result = await api.sendGroupCommand(groupId, type);
const msg = result.offline > 0 showToast(`${type} sent to ${result.sent}/${result.total} devices${result.offline > 0 ? ` (${result.offline} offline)` : ''}`, result.offline > 0 ? 'warning' : 'success');
? 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');
} }
@ -757,10 +389,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(t('dashboard.confirm_delete_group'))) return; if (!confirm('Delete this group? Devices will not be affected.')) return;
try { try {
await api.deleteGroup(id); await api.deleteGroup(id);
showToast(t('dashboard.toast.group_deleted'), 'success'); showToast('Group deleted', 'success');
loadDashboard(); loadDashboard();
} catch (e) { showToast(e.message, 'error'); } } catch (e) { showToast(e.message, 'error'); }
}); });
@ -782,7 +414,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)">${t('dashboard.manage_group_subtitle')}</p> <p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">Check devices to add them to this group</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);
@ -797,7 +429,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">${t('common.done')}</button> <button class="btn" id="manageGroupClose">Done</button>
</div> </div>
</div> </div>
`; `;
@ -810,10 +442,9 @@ 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(t('dashboard.confirm_add_to_group', { name: cbName, groups: existingGroups, target: group.name }))) { if (!confirm(`This device is already in: ${existingGroups}\n\nAdd it to "${group.name}" too?`)) {
cb.checked = false; cb.checked = false;
return; return;
} }
@ -836,17 +467,10 @@ 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,20 +1,16 @@
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 = [
{ id: 'black', value: '#000000' }, { name: 'Black', value: '#000000' },
{ id: 'dark_blue', value: '#0f172a' }, { name: 'Dark Blue', value: '#0f172a' },
{ id: 'dark_gradient', value: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)' }, { name: 'Dark Gradient', value: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)' },
{ id: 'blue_gradient', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }, { name: 'Blue Gradient', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ id: 'sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' }, { name: 'Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
{ id: 'ocean', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' }, { name: 'Ocean', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
{ id: 'forest', value: 'linear-gradient(135deg, #134e5e 0%, #71b280 100%)' }, { name: 'Forest', value: 'linear-gradient(135deg, #134e5e 0%, #71b280 100%)' },
{ id: 'dark_red', value: 'linear-gradient(135deg, #200122 0%, #6f0000 100%)' }, { name: 'Dark Red', value: 'linear-gradient(135deg, #200122 0%, #6f0000 100%)' },
{ id: 'white', value: '#FFFFFF' }, { name: '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'];
@ -34,77 +30,64 @@ export function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<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><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 style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-secondary" id="loadDesignBtn">${t('designer.load_design')}</button> <button class="btn btn-secondary" id="loadDesignBtn">Load Design</button>
<button class="btn btn-secondary" id="exportPngBtn">${t('designer.export_png')}</button> <button class="btn btn-secondary" id="exportPngBtn">Export PNG</button>
<button class="btn btn-primary" id="publishBtn">${t('designer.publish')}</button> <button class="btn btn-primary" id="publishBtn">Publish to Library</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;container-type:inline-size"></div> <div id="designPreview" style="position:relative;width:100%;height:100%;overflow:hidden"></div>
</div> </div>
<p style="font-size:11px;color:var(--text-muted);margin-top:8px">${t('designer.preview_hint')}</p> <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>
</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">${t('designer.add_element')}</h4> <h4 style="font-size:13px;margin-bottom:10px">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; ${t('designer.el.text')}</button> <button class="btn btn-secondary btn-sm" id="addText" style="justify-content:center">&#128172; Text</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="addHeading" style="justify-content:center">&#128220; Heading</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="addImage" style="justify-content:center">&#128247; Image</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="addVideo" style="justify-content:center">&#127916; Video</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="addClock" style="justify-content:center">&#128339; Clock</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="addDate" style="justify-content:center">&#128197; Date</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="addWeather" style="justify-content:center">&#9925; Weather</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="addTicker" style="justify-content:center">&#128240; Ticker</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="addShape" style="justify-content:center">&#9632; Shape</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="addQR" style="justify-content:center">&#9641; QR Code</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="addCountdown" style="justify-content:center">&#9201; Countdown</button>
<button class="btn btn-secondary btn-sm" id="addWebpage" style="justify-content:center">&#127760; ${t('designer.el.webpage')}</button> <button class="btn btn-secondary btn-sm" id="addWebpage" style="justify-content:center">&#127760; 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">${t('designer.background')}</h4> <h4 style="font-size:13px;margin-bottom:8px">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="${t('designer.bg.' + b.id)}"></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="${b.name}"></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">${t('designer.bg_image')}</button> <button class="btn btn-secondary btn-sm" id="bgImageBtn">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">${t('designer.properties')}</h4> <h4 style="font-size:13px">Properties</h4>
<button class="btn btn-danger btn-sm" id="deleteEl">${t('common.delete')}</button> <button class="btn btn-danger btn-sm" id="deleteEl">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">${t('designer.layers')}</h4> <h4 style="font-size:13px;margin-bottom:8px">Layers</h4>
<div id="layerList" style="font-size:12px"></div> <div id="layerList" style="font-size:12px"></div>
</div> </div>
</div> </div>
@ -124,40 +107,9 @@ 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: t('designer.default.text'), fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', bold: false, shadow: false }); 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('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('addHeading').onclick = () => addElement({ type: 'text', x: 5, y: 5, text: '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 = () => {
@ -168,30 +120,30 @@ export function render(container) {
input.click(); input.click();
}; };
document.getElementById('addVideo').onclick = () => { document.getElementById('addVideo').onclick = () => {
const url = prompt(t('designer.prompt.video_url')); const url = prompt('Video URL (MP4):');
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(t('designer.prompt.weather_location'), 'Milwaukee, WI'); const location = prompt('City, State:', '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(t('designer.prompt.rss_url'), 'https://feeds.bbci.co.uk/news/rss.xml'); const url = prompt('RSS Feed 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(t('designer.prompt.qr_url'), 'https://example.com'); const data = prompt('QR Code 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(t('designer.prompt.countdown_date'), '2026-04-01'); const target = prompt('Target date (YYYY-MM-DD):', '2026-04-01');
if (target) addElement({ type: 'countdown', x: 20, y: 40, fontSize: 48, color: '#FFFFFF', targetDate: target, label: t('designer.default.coming_soon') }); if (target) addElement({ type: 'countdown', x: 20, y: 40, fontSize: 48, color: '#FFFFFF', targetDate: target, label: 'Coming Soon' });
}; };
document.getElementById('addWebpage').onclick = () => { document.getElementById('addWebpage').onclick = () => {
const url = prompt(t('designer.prompt.webpage_url')); const url = 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 });
}; };
@ -207,10 +159,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: t('designer.widget_name', { date: new Date().toLocaleDateString() }), config: { html: generateInnerHTML(), css: '', background: bgValue } }) body: JSON.stringify({ widget_type: 'text', name: `Design ${new Date().toLocaleDateString()}`, config: { html: generateInnerHTML(), css: '', background: bgValue } })
}); });
if (res.ok) showToast(t('designer.toast.published'), 'success'); if (res.ok) showToast('Published as widget! Assign it to a layout zone.', 'success');
else showToast(t('designer.toast.publish_failed'), 'error'); else showToast('Publish failed', 'error');
} catch (err) { showToast(err.message, 'error'); } } catch (err) { showToast(err.message, 'error'); }
}; };
@ -255,7 +207,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(t('designer.toast.export_failed', { error: err.message }), 'error'); } } catch (err) { showToast('Export failed: ' + err.message, 'error'); }
}; };
// Load saved design // Load saved design
@ -270,8 +222,8 @@ export function render(container) {
bgValue = data.bgValue || '#000'; bgValue = data.bgValue || '#000';
bgImageDataUrl = data.bgImageDataUrl || null; bgImageDataUrl = data.bgImageDataUrl || null;
redraw(); redraw();
showToast(t('designer.toast.loaded'), 'success'); showToast('Design loaded', 'success');
} catch { showToast(t('designer.toast.invalid_file'), 'error'); } } catch { showToast('Invalid design file', 'error'); }
}; };
reader.readAsText(input.files[0]); reader.readAsText(input.files[0]);
}; };
@ -318,110 +270,6 @@ 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);
@ -449,13 +297,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}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>`; 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>`;
break; break;
case 'clock': case 'clock':
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>`; 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>`;
break; break;
case 'date': case 'date':
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>`; 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>`;
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">`;
@ -467,23 +315,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}cqw;color:${el.color};${border}${cursor}" data-idx="${i}" id="weather_${i}">&#9925; ${t('common.loading')}</div>`; 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>`;
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}cqw;color:${el.color}" id="ticker_${i}">${t('designer.loading_news')}</div> <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>`; </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">${t('designer.qr_label')}</div> <div style="font-size:1.5vw;color:${el.fgColor};font-weight:bold">QR CODE</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}cqw;opacity:0.8">${el.label || ''}</div> <div style="font-size:${el.fontSize / 15}vw;opacity:0.8">${el.label || ''}</div>
<div style="font-size:${el.fontSize / 10}cqw;font-weight:bold" id="countdown_${i}"></div> <div style="font-size:${el.fontSize / 10}vw;font-weight:bold" id="countdown_${i}"></div>
</div>`; </div>`;
break; break;
case 'webpage': case 'webpage':
@ -530,7 +378,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 = t('designer.countdown_now'); return; } if (diff <= 0) { cdEl.textContent = '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);
@ -549,15 +397,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 = ' ' + el.location; }); }).catch(() => { wEl.textContent = '&#9925; ' + 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(' • ') || t('designer.no_items'); tEl.textContent = (d.items || []).map(item => item.title).join(' • ') || 'No items';
}).catch(() => { tEl.textContent = t('designer.feed_unavailable'); }); }).catch(() => { tEl.textContent = 'Feed unavailable'; });
} }
} }
}); });
@ -578,42 +426,42 @@ function updateProps() {
</div>`; </div>`;
if (el.type === 'text') { if (el.type === 'text') {
html += `<div class="form-group"><label>${t('designer.prop.text')}</label><input type="text" class="input" value="${el.text}" data-prop="text"></div> html += `<div class="form-group"><label>Text</label><input type="text" class="input" value="${el.text}" data-prop="text"></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>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.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>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.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>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"> ${t('designer.prop.bold')}</label> <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.shadow ? 'checked' : ''} data-prop="shadow"> ${t('designer.prop.shadow')}</label>`; <label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.shadow ? 'checked' : ''} data-prop="shadow"> Shadow</label>`;
} else if (el.type === 'clock') { } else if (el.type === 'clock') {
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> 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>
<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>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.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>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"> ${t('designer.prop.show_seconds')}</label>`; <label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.showSeconds ? 'checked' : ''} data-prop="showSeconds"> 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"> ${t('designer.prop.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"> Muted</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>`; <label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.loop ? 'checked' : ''} data-prop="loop"> 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>${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>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.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>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.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>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>${t('designer.prop.location')}</label><input type="text" class="input" value="${el.location}" data-prop="location"></div> html += `<div class="form-group"><label>Location</label><input type="text" class="input" value="${el.location}" data-prop="location"></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>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.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></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>`;
} else if (el.type === 'ticker') { } else if (el.type === 'ticker') {
html += `<div class="form-group"><label>${t('designer.prop.feed_url')}</label><input type="text" class="input" value="${el.feedUrl}" data-prop="feedUrl"></div> html += `<div class="form-group"><label>Feed URL</label><input type="text" class="input" value="${el.feedUrl}" data-prop="feedUrl"></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>Speed (seconds)</label><input type="number" class="input" value="${el.speed}" data-prop="speed"></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>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.bg_color')}</label><input type="text" class="input" value="${el.bgColor}" data-prop="bgColor"></div>`; <div class="form-group"><label>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>${t('designer.prop.target_date')}</label><input type="date" class="input" value="${el.targetDate}" data-prop="targetDate"></div> html += `<div class="form-group"><label>Target Date</label><input type="date" class="input" value="${el.targetDate}" data-prop="targetDate"></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>Label</label><input type="text" class="input" value="${el.label}" data-prop="label"></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>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.color')}</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></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>`;
} }
// Save design button // Save design button
@ -622,7 +470,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();
})()">${t('designer.save_design_file')}</button>`; })()">Save Design File</button>`;
fields.innerHTML = html; fields.innerHTML = html;
@ -650,7 +498,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)">${t('designer.no_elements')}</p>`; `).join('') || '<p style="color:var(--text-muted)">No elements yet</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(); };
@ -659,9 +507,6 @@ 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

@ -1,81 +0,0 @@
// #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,13 +1,7 @@
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>${t('help.title')}</h1><div class="subtitle">${t('help.subtitle')}</div></div> <div><h1>Help Center</h1><div class="subtitle">Quick guides and FAQ</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">
@ -15,7 +9,6 @@ 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'] },
@ -32,7 +25,7 @@ export function render(container) {
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>${t('help.faq')}</h3> <h3>Frequently Asked Questions</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.' },
@ -53,10 +46,10 @@ export function render(container) {
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>${t('help.shortcuts')}</h3> <h3>Keyboard 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)">${t('help.shortcut_esc')}</span> <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">F</kbd> <span style="color:var(--text-secondary)">${t('help.shortcut_f')}</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>
</div> </div>
</div> </div>
`; `;

View file

@ -1,6 +1,4 @@
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());
@ -16,17 +14,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>${t('kiosk.title')} <span class="help-tip" data-tip="${t('kiosk.help_tip')}">?</span></h1><div class="subtitle">${t('kiosk.subtitle')}</div></div> <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>
<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>
${t('kiosk.new_page')} New Kiosk 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(t('kiosk.prompt_name')); const name = prompt('Kiosk page 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}`;
@ -36,7 +34,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>${t('kiosk.empty_title')}</h3><p>${t('kiosk.empty_desc')}</p></div>`; 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>';
return; return;
} }
grid.innerHTML = pages.map(p => ` grid.innerHTML = pages.map(p => `
@ -45,27 +43,28 @@ 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">${esc(p.name)}</div> <div class="content-item-name">${p.name}</div>
<div class="content-item-size">${t('kiosk.label')}</div> <div class="content-item-size">Kiosk Page</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()">${t('kiosk.preview')}</a> <a href="/api/kiosk/${p.id}/render" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none" onclick="event.stopPropagation()">Preview</a>
<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> <button class="btn btn-danger btn-sm" data-delete-kiosk="${p.id}" data-kiosk-name="${p.name}" onclick="event.stopPropagation()">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(t('kiosk.confirm_delete', { name }))) return; if (!confirm(`Delete kiosk page "${name}"? This cannot be undone.`)) return;
try { try {
await API(`/kiosk/${btn.dataset.deleteKiosk}`, { method: 'DELETE' }); await API(`/kiosk/${btn.dataset.deleteKiosk}`, { method: 'DELETE' });
showToast(t('kiosk.toast.deleted')); showToast('Kiosk page deleted');
renderList(container); renderList(container);
} catch (err) { } catch (err) {
showToast(err.message || t('kiosk.toast.delete_failed'), 'error'); showToast(err.message || 'Failed to delete', 'error');
} }
}; };
}); });
@ -74,7 +73,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>${t('kiosk.not_found')}</h3></div>`; return; } try { page = await API(`/kiosk/${pageId}`); } catch { container.innerHTML = '<div class="empty-state"><h3>Page 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 = [];
@ -83,47 +82,49 @@ 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>
${t('kiosk.back')} Back to Kiosk Pages
</a> </a>
<div class="page-header"> <div class="page-header">
<h1>${esc(page.name)}</h1> <h1>${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">${t('kiosk.preview')}</a> <a href="/api/kiosk/${pageId}/render" target="_blank" class="btn btn-secondary" style="text-decoration:none">Preview</a>
<button class="btn btn-primary" id="saveKioskBtn">${t('common.save')}</button> <button class="btn btn-primary" id="saveKioskBtn">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">${t('kiosk.page_settings')}</h4> <h4 style="font-size:13px;margin-bottom:10px">Page Settings</h4>
<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>Title</label><input type="text" id="kTitle" class="input" value="${config.title || ''}"></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>Subtitle</label><input type="text" id="kSubtitle" class="input" value="${config.subtitle || ''}"></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>Logo URL</label><input type="text" id="kLogo" class="input" value="${config.logoUrl || ''}" placeholder="https://..."></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>Footer Text</label><input type="text" id="kFooter" class="input" value="${config.footer || ''}"></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 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_timeout')}</label><input type="number" id="kIdleTimeout" class="input" value="${config.idleTimeout || 60}"></div> <div class="form-group"><label>Idle Timeout (seconds)</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">${t('kiosk.style')}</h4> <h4 style="font-size:13px;margin-bottom:10px">Style</h4>
<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>Background</label><input type="text" id="kBg" class="input" value="${config.style?.background || '#111827'}"></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>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.columns')}</label><select id="kColumns" class="input" style="background:var(--bg-input)"> <div class="form-group"><label>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>${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 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_hover')}</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>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> </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">${t('kiosk.buttons')}</h4> <h4 style="font-size:13px">Buttons</h4>
<button class="btn btn-secondary btn-sm" id="addBtnBtn">${t('kiosk.add_btn')}</button> <button class="btn btn-secondary btn-sm" id="addBtnBtn">+ Add</button>
</div> </div>
<div id="buttonList"></div> <div id="buttonList"></div>
</div> </div>
@ -136,22 +137,23 @@ 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="${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.icon || ''}" placeholder="Emoji" style="width:50px;text-align:center" data-btn="${i}" data-field="icon">
<input type="text" class="input" value="${esc(btn.label || '')}" placeholder="${t('kiosk.label_placeholder')}" style="flex:1" data-btn="${i}" data-field="label"> <input type="text" class="input" value="${btn.label || ''}" placeholder="Label" style="flex:1" data-btn="${i}" data-field="label">
</div> </div>
<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"> <input type="text" class="input" value="${btn.sublabel || ''}" placeholder="Sublabel" 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' : ''}>${t('kiosk.action_none')}</option> <option value="" ${!btn.action ? 'selected' : ''}>No action</option>
<option value="url" ${btn.action === 'url' ? 'selected' : ''}>${t('kiosk.action_url')}</option> <option value="url" ${btn.action === 'url' ? 'selected' : ''}>Open URL</option>
<option value="page" ${btn.action === 'page' ? 'selected' : ''}>${t('kiosk.action_page')}</option> <option value="page" ${btn.action === 'page' ? 'selected' : ''}>Go to page</option>
</select> </select>
<button class="btn-icon" style="color:var(--danger)" data-remove-btn="${i}" title="${t('common.delete')}">&#10005;</button> <button class="btn-icon" style="color:var(--danger)" data-remove-btn="${i}" title="Remove">&#10005;</button>
</div> </div>
<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"> <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">
</div> </div>
`).join('') || `<p style="color:var(--text-muted);font-size:12px">${t('kiosk.no_buttons')}</p>`; `).join('') || '<p style="color:var(--text-muted);font-size:12px">No buttons yet</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);
@ -166,7 +168,7 @@ async function renderEditor(container, pageId) {
} }
document.getElementById('addBtnBtn').onclick = () => { document.getElementById('addBtnBtn').onclick = () => {
config.buttons.push({ label: t('kiosk.new_button'), sublabel: '', icon: '&#11088;', action: '', url: '' }); config.buttons.push({ label: 'New Button', sublabel: '', icon: '&#11088;', action: '', url: '' });
renderButtons(); renderButtons();
}; };
@ -188,7 +190,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(t('kiosk.toast.saved'), 'success'); showToast('Kiosk page 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,7 +1,5 @@
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());
@ -17,22 +15,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>${t('layout.title')} <span class="help-tip" data-tip="${t('layout.help_tip')}">?</span></h1><div class="subtitle">${t('layout.subtitle')}</div></div> <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>
<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>
${t('layout.new_layout')} New Layout
</button> </button>
</div> </div>
<h3 style="margin-bottom:12px;font-size:14px;color:var(--text-secondary)">${t('layout.templates')}</h3> <h3 style="margin-bottom:12px;font-size:14px;color:var(--text-secondary)">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)">${t('layout.my_layouts')}</h3> <h3 style="margin:24px 0 12px;font-size:14px;color:var(--text-secondary)">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(t('layout.prompt_name')); const name = prompt('Layout name:');
if (!name) return; if (!name) return;
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 }] }) }); 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 }] }) });
window.location.hash = `#/layout/${layout.id}`; window.location.hash = `#/layout/${layout.id}`;
}; };
@ -43,8 +41,9 @@ 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>${t('layout.empty_custom')}</p></div>`; '<div class="empty-state" style="grid-column:1/-1"><p>No custom layouts yet</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: '{}' });
@ -52,21 +51,23 @@ 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(t('layout.confirm_delete', { name }))) return; if (!confirm(`Delete layout "${name}"? This cannot be undone.`)) return;
try { try {
await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' }); await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' });
showToast(t('layout.toast.deleted')); showToast('Layout deleted');
renderList(container); renderList(container);
} catch (err) { } catch (err) {
showToast(err.message || t('layout.toast.delete_failed'), 'error'); showToast(err.message || 'Failed to delete layout', 'error');
} }
}; };
}); });
@ -76,8 +77,6 @@ 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)">
@ -85,20 +84,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">${esc(z.name)}</div> font-size:9px;color:var(--text-muted);overflow:hidden">${z.name}</div>
`).join('')} `).join('')}
</div> </div>
</div> </div>
<div class="content-item-body"> <div class="content-item-body">
<div class="content-item-name">${esc(layout.name)}</div> <div class="content-item-name">${layout.name}</div>
<div class="content-item-size">${zonesText}${isTemplate ? ' • ' + t('layout.template_label') : ''}</div> <div class="content-item-size">${layout.zones?.length || 0} zone(s) ${isTemplate ? '• Template' : ''}</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}">${t('layout.use_template')}</button>` ? `<button class="btn btn-primary btn-sm" data-use-template="${layout.id}">Use Template</button>`
: `<button class="btn btn-secondary btn-sm" data-edit-layout="${layout.id}">${t('common.edit')}</button>` : `<button class="btn btn-secondary btn-sm" data-edit-layout="${layout.id}">Edit</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> <button class="btn btn-danger btn-sm" data-delete-layout="${layout.id}" data-layout-name="${layout.name}" style="margin-left:4px">Delete</button>
</div> </div>
</div> </div>
`; `;
@ -108,51 +107,44 @@ 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>${t('layout.not_found')}</h3></div>`; return; } } catch { container.innerHTML = '<div class="empty-state"><h3>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>
${t('layout.back')} Back to Layouts
</a> </a>
<div class="page-header"> <div class="page-header">
<h1 id="layoutName">${esc(layout.name)}</h1> <h1 id="layoutName">${layout.name}</h1>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="addZoneBtn">${t('layout.add_zone')}</button> <button class="btn btn-secondary btn-sm" id="addZoneBtn">Add Zone</button>
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">${t('common.save')}</button> <button class="btn btn-primary btn-sm" id="saveLayoutBtn">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">${t('layout.zones')}</h3> <h3 style="font-size:14px;margin-bottom:12px">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">${t('layout.properties')}</h3> <h3 style="font-size:14px;margin-bottom:12px">Properties</h3>
<div class="form-group"><label>${t('layout.prop.name')}</label><input type="text" id="propName" class="input"></div> <div class="form-group"><label>Name</label><input type="text" id="propName" class="input"></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>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.y')}</label><input type="number" id="propY" 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.width')}</label><input type="number" id="propW" class="input" min="1" 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.height')}</label><input type="number" id="propH" 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.type')}</label> <div class="form-group"><label>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">${t('layout.type_content')}</option><option value="widget">${t('layout.type_widget')}</option> <option value="content">Content</option><option value="widget">Widget</option>
</select> </select>
</div> </div>
<div class="form-group"><label>${t('layout.prop.fit')}</label> <button class="btn btn-danger btn-sm" id="deleteZoneBtn" style="width:100%;justify-content:center;margin-top:8px">Delete Zone</button>
<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>
@ -164,6 +156,7 @@ 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) => {
@ -177,6 +170,7 @@ 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();
@ -206,6 +200,7 @@ 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) => {
@ -233,12 +228,13 @@ 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">${esc(z.name)}</div> <div style="font-weight:500">${z.name}</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 style="font-size:11px;color:var(--text-muted)">${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}% ${z.zone_type}</div>
</div> </div>
`).join(''); `).join('');
@ -258,10 +254,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';
} }
['propName', 'propX', 'propY', 'propW', 'propH', 'propType', 'propFit'].forEach(id => { // Property input handlers
['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];
@ -271,13 +267,12 @@ 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: 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 }); 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 });
selectedZone = zones.length - 1; selectedZone = zones.length - 1;
renderZones(); renderZones();
updateProperties(); updateProperties();
@ -293,21 +288,16 @@ async function renderEditor(container, layoutId) {
document.getElementById('saveLayoutBtn').onclick = async () => { document.getElementById('saveLayoutBtn').onclick = async () => {
try { try {
// Single atomic update: send the full zone set and the server replaces them // Delete existing zones and recreate
// exactly. The old per-zone delete-then-add loop could accumulate zones for (const z of layout.zones || []) {
// (and regenerated every zone id each save). Keep each zone's id so await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' });
// device->zone assignments survive. }
const updated = await API(`/layouts/${layoutId}`, { for (const z of zones) {
method: 'PUT', await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) });
body: JSON.stringify({ zones }), }
}); showToast('Layout saved', 'success');
if (updated && updated.error) { showToast(updated.error, 'error'); return; } layout = await API(`/layouts/${layoutId}`);
layout = updated; zones = layout.zones;
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,5 +1,4 @@
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
let authConfig = null; let authConfig = null;
@ -10,88 +9,49 @@ async function loadAuthConfig() {
return authConfig; return authConfig;
} }
// #15: resolve instance/default branding for the (pre-login) login page.
// Public endpoint: custom-domain match -> platform default -> ScreenTinker.
async function loadLoginBranding() {
try {
const res = await fetch('/api/branding?domain=' + encodeURIComponent(location.hostname));
if (!res.ok) return {};
return await res.json();
} catch { return {}; }
}
function brandEsc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
// 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) { export async function render(container) {
const [config, branding] = await Promise.all([loadAuthConfig(), loadLoginBranding()]); const config = await loadAuthConfig();
const isSetup = config.needsSetup; 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); container.innerHTML = `
const brandName = branding.brand_name || 'ScreenTinker'; <div style="display:flex;align-items:center;justify-content:center;height:100vh;margin-left:calc(-1 * var(--sidebar-width))">
// Branded logo if set, else the default ScreenTinker glyph. <div style="width:400px;max-width:90vw">
const logoHtml = branding.logo_url <div style="text-align:center;margin-bottom:32px">
? `<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">
: `<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 ? t('auth.subtitle_setup') : t('auth.subtitle_signin')} ${isSetup ? 'Create your admin account to get started' : 'Sign in to manage your displays'}
</p> </p>
${!isSetup && canRegister ? `<p style="color:var(--warning);font-size:12px;margin-top:8px">${t('auth.trial_notice')}</p>` : ''} ${isSetup ? '' : '<p style="color:var(--warning);font-size:12px;margin-top:8px">New accounts get a 14-day free Pro trial</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>${t('auth.email')}</label> <label>Email</label>
<input type="email" id="loginEmail" class="input" placeholder="${t('auth.placeholder_email')}" autocomplete="email"> <input type="email" id="loginEmail" class="input" placeholder="you@example.com" autocomplete="email">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${t('auth.password')}</label> <label>Password</label>
<input type="password" id="loginPassword" class="input" placeholder="${t('auth.placeholder_password')}" autocomplete="current-password"> <input type="password" id="loginPassword" class="input" placeholder="••••••••" autocomplete="current-password">
</div> </div>
${isSetup ? ` ${isSetup ? `
<div class="form-group"> <div class="form-group">
<label>${t('auth.name')}</label> <label>Name</label>
<input type="text" id="loginName" class="input" placeholder="${t('auth.placeholder_name')}"> <input type="text" id="loginName" class="input" placeholder="Your 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 ? t('auth.create_admin_account') : t('auth.sign_in')} ${isSetup ? 'Create Admin Account' : 'Sign In'}
</button> </button>
${!isSetup && canRegister ? ` ${!isSetup ? `
<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">
${t('auth.create_account')} Create Account
</button> </button>
` : ''} ` : ''}
</div> </div>
@ -99,29 +59,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>${t('auth.name')}</label> <label>Name</label>
<input type="text" id="regName" class="input" placeholder="${t('auth.placeholder_name')}"> <input type="text" id="regName" class="input" placeholder="Your name">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${t('auth.email')}</label> <label>Email</label>
<input type="email" id="regEmail" class="input" placeholder="${t('auth.placeholder_email')}"> <input type="email" id="regEmail" class="input" placeholder="you@example.com">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${t('auth.password')}</label> <label>Password</label>
<input type="password" id="regPassword" class="input" placeholder="${t('auth.placeholder_register_password')}"> <input type="password" id="regPassword" class="input" placeholder="At least 6 characters">
</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">
${t('auth.create_account')} 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">
${t('auth.back_to_signin')} Back to Sign In
</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">${t('auth.divider_or')}</span> <span style="color:var(--text-muted);font-size:12px">OR</span>
<hr style="flex:1;border-color:var(--border)"> <hr style="flex:1;border-color:var(--border)">
</div> </div>
` : ''} ` : ''}
@ -135,7 +95,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>
${t('auth.signin_google')} Sign in with Google
</button> </button>
</div> </div>
` : ''} ` : ''}
@ -148,25 +108,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>
${t('auth.signin_microsoft')} Sign in with 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">${t('auth.support_access')}</summary> <summary style="font-size:11px;color:var(--text-muted);cursor:pointer;text-align:center">Support Access</summary>
<div style="margin-top:8px"> <div style="margin-top:8px">
<input type="text" id="supportToken" class="input" placeholder="${t('auth.support_token_placeholder')}" style="font-family:monospace"> <input type="text" id="supportToken" class="input" placeholder="Paste support token" style="font-family:monospace;font-size:11px">
<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> <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>
</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">${t('auth.terms')}</a> <a href="/legal/terms.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">Terms of Service</a>
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
<a href="/legal/privacy.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">${t('auth.privacy')}</a> <a href="/legal/privacy.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">Privacy Policy</a>
</p> </p>
</div> </div>
</div> </div>
@ -185,7 +145,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(t('auth.error_paste_support_token')); return; } if (!token) { showError('Paste a support token'); return; }
try { try {
const res = await fetch('/api/auth/support', { const res = await fetch('/api/auth/support', {
method: 'POST', method: 'POST',
@ -195,7 +155,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(t('auth.error_support_failed')); } } catch (err) { showError('Support login failed'); }
}); });
// Local login/register // Local login/register
@ -222,7 +182,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(t('auth.error_email_password_required')); return; } if (!email || !password) { showError('Email and password required'); return; }
try { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/login', {
@ -234,7 +194,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(t('auth.error_login_failed')); showError('Login failed');
} }
} }
@ -242,8 +202,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(t('auth.error_email_password_required')); return; } if (!email || !password) { showError('Email and password required'); return; }
if (password.length < 6) { showError(t('auth.error_password_min_6')); return; } if (password.length < 6) { showError('Password must be at least 6 characters'); return; }
try { try {
const res = await fetch('/api/auth/register', { const res = await fetch('/api/auth/register', {
@ -255,7 +215,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(t('auth.error_registration_failed')); showError('Registration failed');
} }
} }
@ -286,7 +246,7 @@ function setupHandlers(config, isSetup) {
}); });
client.requestAccessToken(); client.requestAccessToken();
} catch (err) { } catch (err) {
showError(t('auth.error_google_failed')); showError('Google sign-in failed');
} }
}); });
} }
@ -316,7 +276,7 @@ function setupHandlers(config, isSetup) {
else showError(data.error); else showError(data.error);
} }
} catch (err) { } catch (err) {
showError(t('auth.error_microsoft_failed')); showError('Microsoft sign-in failed');
} }
}); });
} }

View file

@ -1,31 +0,0 @@
// #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,101 +1,96 @@
import { showToast } from '../components/toast.js'; import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
// Steps are computed lazily so translated strings refresh on language change. const STEPS = [
function getSteps() {
return [
{ {
title: t('onboarding.step.welcome.title'), title: 'Welcome to ScreenTinker!',
icon: '&#128075;', icon: '&#128075;',
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.welcome.intro')}</p> content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:16px">Let's get you set up in under 5 minutes.</p>
<p style="color:var(--text-muted);font-size:14px">${t('onboarding.step.welcome.guide_through')}</p> <p style="color:var(--text-muted);font-size:14px">This wizard will guide you 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>${t('onboarding.step.welcome.bullet_download')}</li> <li>Downloading the player app</li>
<li>${t('onboarding.step.welcome.bullet_pair')}</li> <li>Pairing your first display</li>
<li>${t('onboarding.step.welcome.bullet_upload')}</li> <li>Uploading and assigning content</li>
</ul>`, </ul>`,
action: null action: null
}, },
{ {
title: t('onboarding.step.player.title'), title: 'Step 1: Get the Player App',
icon: '&#128229;', icon: '&#128229;',
content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.player.intro')}</p> content: `<p style="color:var(--text-secondary);margin-bottom:16px">Install the player on your display device.</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">${t('onboarding.step.player.android_label')}</div> <div style="font-weight:600;font-size:14px">Android APK</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('onboarding.step.player.android_desc')}</div> <div style="font-size:11px;color:var(--text-muted);margin-top:4px">TV boxes, tablets, Fire TV</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">${t('onboarding.step.player.web_label')}</div> <div style="font-weight:600;font-size:14px">Web Player</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('onboarding.step.player.web_desc')}</div> <div style="font-size:11px;color:var(--text-muted);margin-top:4px">Any browser, Pi, ChromeOS</div>
</a> </a>
</div> </div>
<p style="color:var(--text-muted);font-size:12px;margin-top:12px">${t('onboarding.step.player.url_hint')}</p> <p style="color:var(--text-muted);font-size:12px;margin-top:12px">Open the app on your display and enter this server URL:</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: t('onboarding.step.pair.title'), title: 'Step 2: Pair Your Display',
icon: '&#128279;', icon: '&#128279;',
content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.pair.intro')}</p> content: `<p style="color:var(--text-secondary);margin-bottom:16px">Enter the 6-digit code shown on your display.</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="max-width:240px;width:100%;padding:16px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px; style="width:240px;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="${t('onboarding.step.pair.name_placeholder')}" <input type="text" id="onboardDeviceName" placeholder="Display name (e.g., Lobby TV)"
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"> 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">
</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: t('onboarding.step.upload.title'), title: 'Step 3: Upload Content',
icon: '&#128228;', icon: '&#128228;',
content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.upload.intro')}</p> content: `<p style="color:var(--text-secondary);margin-bottom:16px">Upload a video or image to display.</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)">${t('onboarding.step.upload.click_to_select')}</p> <p style="color:var(--text-secondary)">Click to select a file</p>
<p style="color:var(--text-muted);font-size:12px;margin-top:4px">${t('onboarding.step.upload.formats')}</p> <p style="color:var(--text-muted);font-size:12px;margin-top:4px">MP4, WebM, JPEG, PNG, GIF</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">${t('onboarding.step.upload.uploading')}</p> <p id="onboardUploadText" style="font-size:12px;color:var(--text-muted);margin-top:6px">Uploading...</p>
</div>`, </div>`,
action: 'upload' action: 'upload'
}, },
{ {
title: t('onboarding.step.done.title'), title: "You're All Set!",
icon: '&#127881;', icon: '&#127881;',
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:20px">${t('onboarding.step.done.intro')}</p> content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:20px">Your display is paired and content is playing!</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">${t('onboarding.step.done.whats_next')}</p> <p style="font-size:14px;color:var(--text-primary);font-weight:600;margin-bottom:8px">What's 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>${t('onboarding.step.done.next_content')}</li> <li>Add more content in the <strong>Content Library</strong></li>
<li>${t('onboarding.step.done.next_layouts')}</li> <li>Create multi-zone layouts in <strong>Layouts</strong></li>
<li>${t('onboarding.step.done.next_schedule')}</li> <li>Set up a schedule in the <strong>Schedule</strong> calendar</li>
<li>${t('onboarding.step.done.next_widgets')}</li> <li>Add live widgets (clock, weather, ticker) in <strong>Widgets</strong></li>
<li>${t('onboarding.step.done.next_kiosk')}</li> <li>Create interactive screens in <strong>Kiosk</strong></li>
<li>${t('onboarding.step.done.next_designer')}</li> <li>Design custom content in the <strong>Designer</strong></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;
@ -118,16 +113,17 @@ 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">${t('onboarding.back')}</button>`} ${isFirst ? '<div></div>' : `<button class="btn btn-secondary" id="prevBtn">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)">${t('onboarding.skip')}</button>` : ''} ${!isLast ? `<button class="btn btn-secondary" id="skipBtn" style="color:var(--text-muted)">Skip Wizard</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> <button class="btn btn-primary" id="nextBtn">${isLast ? 'Go to Dashboard' : step.action ? (step.action === 'pair' ? 'Pair Display' : 'Next') : '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');
@ -136,6 +132,7 @@ 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');
@ -145,7 +142,6 @@ 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') {
@ -154,12 +150,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 = t('onboarding.toast.invalid_code'); if (status) status.textContent = 'Enter a valid 6-digit code';
return; return;
} }
try { try {
if (status) status.textContent = t('onboarding.toast.pairing'); if (status) status.textContent = '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',
@ -167,13 +163,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 || t('onboarding.toast.pair_failed'); return; } if (!res.ok) { if (status) status.textContent = data.error || 'Pairing failed'; return; }
pairedDeviceId = data.id; pairedDeviceId = data.id;
showToast(t('onboarding.toast.paired'), 'success'); showToast('Display paired!', 'success');
currentStep++; currentStep++;
renderStep(); renderStep();
} catch (err) { } catch (err) {
if (status) status.textContent = t('onboarding.toast.pair_failed_with_error', { error: err.message }); if (status) status.textContent = 'Pairing failed: ' + err.message;
} }
return; return;
} }
@ -212,8 +208,9 @@ 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 = t('onboarding.toast.uploaded_assigning'); if (text) text.textContent = 'Uploaded! Assigning to display...';
// Auto-assign to paired device
if (pairedDeviceId) { if (pairedDeviceId) {
try { try {
await fetch(`/api/assignments/device/${pairedDeviceId}`, { await fetch(`/api/assignments/device/${pairedDeviceId}`, {
@ -224,17 +221,17 @@ export function render(container) {
} catch {} } catch {}
} }
showToast(t('onboarding.toast.content_assigned'), 'success'); showToast('Content uploaded and assigned!', 'success');
currentStep++; currentStep++;
renderStep(); renderStep();
} else { } else {
if (text) text.textContent = t('onboarding.toast.upload_failed'); if (text) text.textContent = 'Upload failed';
} }
}; };
xhr.onerror = () => { if (text) text.textContent = t('onboarding.toast.upload_failed'); }; xhr.onerror = () => { if (text) text.textContent = 'Upload failed'; };
xhr.send(formData); xhr.send(formData);
} catch (err) { } catch (err) {
if (text) text.textContent = t('onboarding.toast.error_with_error', { error: err.message }); if (text) text.textContent = 'Error: ' + err.message;
} }
} }

View file

@ -1,7 +1,6 @@
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 '--';
@ -14,39 +13,6 @@ 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) {
@ -65,25 +31,27 @@ 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>${t('playlist.title')}</h1> <h1>Playlists</h1>
<div class="subtitle">${t('playlist.subtitle')}</div> <div class="subtitle">Create and manage content playlists</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' : ''}>
${t('playlist.show_auto_generated')} Show auto-generated
</label> </label>
<button class="btn btn-primary" id="createPlaylistBtn">${t('playlist.new_playlist_btn')}</button> <button class="btn btn-primary" id="createPlaylistBtn">+ New Playlist</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">${t('common.loading')}</div> <div style="color:var(--text-muted);padding:40px;text-align:center">Loading...</div>
</div> </div>
`; `;
@ -108,8 +76,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)">${t('playlist.empty_title')}</h3> <h3 style="margin-bottom:8px;color:var(--text-primary)">No playlists yet</h3>
<p>${t('playlist.empty_desc')}</p> <p>Create your first playlist to organize content for your displays.</p>
</div> </div>
`; `;
return; return;
@ -119,7 +87,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 ? t('playlist.all_auto_generated') : ''} ${playlists.length ? 'All playlists are auto-generated. Toggle "Show auto-generated" to see them.' : ''}
</div> </div>
`; `;
return; return;
@ -130,20 +98,19 @@ 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)">${t('playlist.tag_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)">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">${tn('playlist.item_count', p.item_count)}</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> </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>${t('playlist.created_at', { date: formatDate(p.created_at) })}</span> <span>Created ${formatDate(p.created_at)}</span>
${p.display_count ? `<span>${tn('playlist.display_count', p.display_count)}</span>` : ''} ${p.display_count ? `<span>${p.display_count} display${p.display_count !== 1 ? 's' : ''}</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">${t('playlist.load_failed', { error: esc(err.message) })}</div>`; 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>`;
} }
} }
@ -152,12 +119,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)">${t('playlist.new_playlist')}</h3> <h3 style="margin-bottom:16px;color:var(--text-primary)">New Playlist</h3>
<input type="text" id="newPlaylistName" class="input" placeholder="${t('playlist.name_placeholder')}" style="width:100%;margin-bottom:12px" autofocus> <input type="text" id="newPlaylistName" class="input" placeholder="Playlist name" style="width:100%;margin-bottom:12px" autofocus>
<textarea id="newPlaylistDesc" class="input" placeholder="${t('playlist.desc_placeholder')}" style="width:100%;height:60px;resize:vertical;margin-bottom:16px"></textarea> <textarea id="newPlaylistDesc" class="input" placeholder="Description (optional)" 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">${t('common.cancel')}</button> <button class="btn btn-secondary" id="cancelCreateBtn">Cancel</button>
<button class="btn btn-primary" id="confirmCreateBtn">${t('playlist.create_btn')}</button> <button class="btn btn-primary" id="confirmCreateBtn">Create</button>
</div> </div>
</div> </div>
`; `;
@ -176,7 +143,7 @@ function showCreateModal() {
try { try {
const pl = await api.createPlaylist(name, desc); const pl = await api.createPlaylist(name, desc);
modal.remove(); modal.remove();
showToast(t('playlist.toast.created')); showToast('Playlist 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');
@ -187,9 +154,11 @@ 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">${t('common.loading')}</div> <div style="color:var(--text-muted);padding:40px;text-align:center">Loading...</div>
`; `;
try { try {
@ -198,46 +167,27 @@ 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>${t('playlist.load_failed', { error: esc(err.message) })}</p> <p>Failed to load playlist: ${esc(err.message)}</p>
<a href="#/playlists" class="btn btn-secondary" style="margin-top:16px">${t('playlist.back_to_playlists')}</a> <a href="#/playlists" class="btn btn-secondary" style="margin-top:16px">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="${t('playlist.back')}">&larr;</a> <a href="#/playlists" style="color:var(--text-muted);text-decoration:none;font-size:20px" title="Back">&larr;</a>
<div> <div>
<h1 id="playlistTitle" style="cursor:pointer" title="${t('playlist.click_to_rename')}">${esc(playlist.name)}</h1> <h1 id="playlistTitle" style="cursor:pointer" title="Click to rename">${esc(playlist.name)}</h1>
<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> <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>
${playlist.display_count ? `<div style="font-size:12px;color:var(--text-muted);margin-top:4px">${tn('playlist.assigned_to', playlist.display_count)}</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>` : ''}
</div> </div>
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn btn-primary" id="addItemBtn">${t('playlist.add_content')}</button> <button class="btn btn-primary" id="addItemBtn">+ Add Content</button>
<button class="btn btn-secondary" id="deletePlaylistBtn" style="color:var(--danger)">${t('playlist.delete_playlist')}</button> <button class="btn btn-secondary" id="deletePlaylistBtn" style="color:var(--danger)">Delete Playlist</button>
</div> </div>
</div> </div>
@ -247,46 +197,19 @@ function renderDetailContent(container, playlist) {
renderItems(playlist.items || []); renderItems(playlist.items || []);
const publishBtn = document.getElementById('publishBtn'); // Inline rename
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(t('playlist.confirm_delete', { name: playlist.name }))) return; if (!confirm(`Delete "${playlist.name}"? This cannot be undone.`)) return;
try { try {
await api.deletePlaylist(playlist.id); await api.deletePlaylist(playlist.id);
showToast(t('playlist.toast.deleted')); showToast('Playlist deleted');
window.location.hash = '#/playlists'; window.location.hash = '#/playlists';
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -294,16 +217,6 @@ 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;
@ -311,51 +224,38 @@ 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">${t('playlist.items_empty')}</p> <p style="margin-bottom:8px">This playlist is empty</p>
<p style="font-size:13px">${t('playlist.items_empty_hint')}</p> <p style="font-size:13px">Click "Add Content" to add items.</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}" 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 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 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="/api/content/${esc(item.content_id)}/thumbnail" style="width:100%;height:100%;object-fit:cover">` ? `<img src="/uploads/thumbnails/${esc(item.thumbnail_path.split('/').pop())}" 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 || t('common.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 || 'Unknown')}</div>
<div style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:8px;min-width:0"> <div style="font-size:12px;color:var(--text-muted)">${item.widget_id ? 'Widget' : (item.mime_type || 'Unknown type')}</div>
<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)">${t('playlist.duration')}</label> <label style="font-size:12px;color:var(--text-muted)">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)">${t('playlist.sec')}</span> <span style="font-size:12px;color:var(--text-muted)">sec</span>
</div> </div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0"> <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">
<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;
@ -363,13 +263,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;
@ -377,43 +277,14 @@ 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 || []);
refreshAfterMutation(); showToast('Item removed');
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);
} }
@ -448,23 +319,27 @@ 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;
@ -494,7 +369,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 = t('playlist.click_to_rename'); newEl.title = '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'));
@ -522,11 +397,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 = t('playlist.click_to_edit_desc'); newEl.title = 'Click to edit description';
if (playlist.description) { if (playlist.description) {
newEl.textContent = playlist.description; newEl.textContent = playlist.description;
} else { } else {
newEl.innerHTML = `<span style="opacity:0.5">${t('playlist.add_desc_placeholder')}</span>`; newEl.innerHTML = '<span style="opacity:0.5">Add a description...</span>';
} }
input.replaceWith(newEl); input.replaceWith(newEl);
newEl.addEventListener('click', () => inlineEdit(playlist, 'description')); newEl.addEventListener('click', () => inlineEdit(playlist, 'description'));
@ -537,20 +412,22 @@ 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;max-width:560px;width:95vw;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;width:560px;max-width:90vw;max-height:80vh;display:flex;flex-direction:column">
<h3 style="margin-bottom:16px;color:var(--text-primary)">${t('playlist.add_modal_title')}</h3> <h3 style="margin-bottom:16px;color:var(--text-primary)">Add Content to Playlist</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">${t('playlist.tab_content')}</button> <button class="btn btn-primary btn-sm tab-btn active" data-tab="content">Content</button>
<button class="btn btn-secondary btn-sm tab-btn" data-tab="widgets">${t('playlist.tab_widgets')}</button> <button class="btn btn-secondary btn-sm tab-btn" data-tab="widgets">Widgets</button>
</div> </div>
<input type="text" id="addItemSearch" class="input" placeholder="${t('playlist.search_placeholder')}" style="width:100%;margin-bottom:12px"> <input type="text" id="addItemSearch" class="input" placeholder="Search..." 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">${t('playlist.close')}</button> <button class="btn btn-secondary" id="closeAddModal">Close</button>
</div> </div>
</div> </div>
`; `;
@ -560,13 +437,14 @@ 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">${t('playlist.load_failed', { error: esc(err.message) })}</div>`; document.getElementById('addItemList').innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">Failed to load: ${esc(err.message)}</div>`;
} }
function renderTab() { function renderTab() {
@ -579,15 +457,15 @@ async function showAddItemModal(playlistId) {
}); });
if (!filtered.length) { if (!filtered.length) {
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>`; list.innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">No ${activeTab} 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 || t('common.unknown'); const name = item.filename || item.name || 'Unknown';
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : (item.mime_type || ''); const sub = isWidget ? (item.widget_type || 'Widget') : (item.mime_type || '');
const thumb = item.thumbnail_path ? `/api/content/${esc(item.id)}/thumbnail` : null; const thumb = item.thumbnail_path ? `/uploads/thumbnails/${esc(item.thumbnail_path.split('/').pop())}` : 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">
@ -597,11 +475,12 @@ 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'}">${t('playlist.add_btn')}</button> <button class="btn btn-primary btn-sm add-item-btn" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}">Add</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();
@ -610,21 +489,24 @@ 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 = t('playlist.adding'); btn.textContent = 'Adding...';
await api.addPlaylistItem(playlistId, data); await api.addPlaylistItem(playlistId, data);
btn.textContent = t('playlist.added'); btn.textContent = 'Added';
btn.classList.remove('btn-primary'); btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary'); btn.classList.add('btn-secondary');
refreshAfterMutation(); // Refresh the detail view items
const playlist = await api.getPlaylist(playlistId);
renderItems(playlist.items || []);
} catch (err) { } catch (err) {
btn.disabled = false; btn.disabled = false;
btn.textContent = t('playlist.add_btn'); btn.textContent = 'Add';
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;
@ -637,117 +519,12 @@ 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,7 +1,6 @@
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());
@ -13,36 +12,36 @@ export async function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
<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> <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>
<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>
${t('report.export_csv')} 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>${t('report.device')}</label> <div class="form-group" style="margin:0"><label>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="">${t('report.all_devices')}</option> <option value="">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>${t('report.start_date')}</label> <div class="form-group" style="margin:0"><label>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>${t('report.end_date')}</label> <div class="form-group" style="margin:0"><label>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">${t('report.load_report')}</button> <button class="btn btn-primary btn-sm" id="loadReportBtn">Load Report</button>
</div> </div>
<div id="reportContent"><div class="empty-state"><h3>${t('report.select_range')}</h3></div></div> <div id="reportContent"><div class="empty-state"><h3>Select a date range and click Load Report</h3></div></div>
`; `;
document.getElementById('loadReportBtn').onclick = loadReport; document.getElementById('loadReportBtn').onclick = loadReport;
loadReport(); loadReport(); // Auto-load on page render
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;
@ -57,79 +56,81 @@ 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>${t('common.loading')}</h3></div>`; content.innerHTML = '<div class="empty-state"><h3>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">${t('report.total_plays')}</div> <div class="info-card-label">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">${t('report.total_hours')}</div> <div class="info-card-label">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">${t('report.unique_content')}</div> <div class="info-card-label">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">${t('report.active_devices')}</div> <div class="info-card-label">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">${t('report.avg_duration')}</div> <div class="info-card-label">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">${t('report.plays_per_day')}</h3> <h3 style="font-size:14px;margin-bottom:12px">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">${t('report.plays_by_hour')}</h3> <h3 style="font-size:14px;margin-bottom:12px">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">${t('report.top_content')}</h3> <h3 style="font-size:14px;margin-bottom:12px">Top Content</h3>
<div class="table-wrap"> <table style="width:100%;border-collapse:collapse;font-size:13px">
<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)">${t('report.col.content')}</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">Content</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)">Plays</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)">Total Hours</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">${t('report.col.completion')}</th> <th style="padding:8px;text-align:right;color:var(--text-muted)">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 || t('common.unknown')}</td> <td style="padding:8px">${c.content_name || '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)">${t('report.no_data')}</td></tr>`} `).join('') || '<tr><td colspan="4" style="padding:16px;text-align:center;color:var(--text-muted)">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">${t('report.by_device')}</h3> <h3 style="font-size:14px;margin-bottom:12px">By Device</h3>
<div class="table-wrap"> <table style="width:100%;border-collapse:collapse;font-size:13px">
<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)">${t('report.col.device')}</th> <th style="padding:8px;text-align:left;color:var(--text-muted)">Device</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)">Plays</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)">Total Hours</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
${summary.by_device.map(d => ` ${summary.by_device.map(d => `
@ -138,18 +139,19 @@ 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)">${t('report.no_data')}</td></tr>`} `).join('') || '<tr><td colspan="3" style="padding:16px;text-align:center;color:var(--text-muted)">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(undefined, { month: 'short', day: 'numeric' }), label: new Date(d.day).toLocaleDateString('en-US', { 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 };
@ -157,7 +159,7 @@ export async function render(container) {
renderBarChart('hourlyChart', hourData); renderBarChart('hourlyChart', hourData);
} catch (err) { } catch (err) {
content.innerHTML = `<div class="empty-state"><h3>${t('report.error')}</h3><p>${esc(err.message)}</p></div>`; content.innerHTML = `<div class="empty-state"><h3>Error</h3><p>${esc(err.message)}</p></div>`;
} }
} }
} }

View file

@ -1,118 +1,72 @@
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, content, groups, playlists, layoutsRaw] = await Promise.all([ const devices = await api.getDevices();
api.getDevices(), const content = await api.getContent();
api.getContent(), const selectedDevice = devices[0]?.id || '';
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>${t('schedule.title')} <span class="help-tip" data-tip="${t('schedule.help_tip')}">?</span></h1><div class="subtitle">${t('schedule.subtitle')}</div></div> <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> </div>
<div class="schedule-controls" style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap"> <div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
<select id="schedDevice" class="input" style="width:200px;max-width:100%;background:var(--bg-input)"> <select id="schedDevice" class="input" style="width:200px;background:var(--bg-input)">
${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')} ${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
</select> </select>
<button class="btn btn-secondary btn-sm" id="prevWeek">${t('schedule.prev_week')}</button> <button class="btn btn-secondary btn-sm" id="prevWeek">&lt; Prev</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">${t('schedule.next_week')}</button> <button class="btn btn-secondary btn-sm" id="nextWeek">Next &gt;</button>
<button class="btn btn-primary btn-sm" id="addScheduleBtn">${t('schedule.add_schedule')}</button> <button class="btn btn-primary btn-sm" id="addScheduleBtn">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">${t('schedule.add_schedule')}</h3> <div class="modal-header"><h3 id="schedModalTitle">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>${t('schedule.apply_to')}</label> <div class="form-group"><label>Content</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)">
<option value="">${t('schedule.content_none')}</option> ${content.map(c => `<option value="${c.id}">${c.filename}</option>`).join('')}
${content.map(c => `<option value="${esc(c.id)}">${esc(c.filename)}</option>`).join('')}
</select> </select>
</div> </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 class="form-group"><label>Title (optional)</label><input type="text" id="schedTitle" class="input" placeholder="e.g., Morning Playlist"></div>
<div style="display:flex;gap:12px"> <div style="display:flex;gap:12px">
<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>Start Time</label><input type="time" id="schedStart" class="input" value="09: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 class="form-group" style="flex:1"><label>End Time</label><input type="time" id="schedEnd" class="input" value="17:00"></div>
</div> </div>
<div class="form-group"><label>${t('schedule.repeat')}</label> <div class="form-group"><label>Repeat</label>
<select id="schedRepeat" class="input" style="background:var(--bg-input)"> <select id="schedRepeat" class="input" style="background:var(--bg-input)">
<option value="">${t('schedule.repeat_none')}</option> <option value="">No repeat</option>
<option value="FREQ=DAILY">${t('schedule.repeat_daily')}</option> <option value="FREQ=DAILY">Daily</option>
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">${t('schedule.repeat_weekdays')}</option> <option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">Weekdays</option>
<option value="FREQ=WEEKLY;BYDAY=SA,SU">${t('schedule.repeat_weekends')}</option> <option value="FREQ=WEEKLY;BYDAY=SA,SU">Weekends</option>
<option value="FREQ=WEEKLY">${t('schedule.repeat_weekly')}</option> <option value="FREQ=WEEKLY">Weekly</option>
</select> </select>
</div> </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>Priority</label><input type="number" id="schedPriority" class="input" value="0" min="0" max="100"></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 class="form-group"><label>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>
@ -121,29 +75,11 @@ 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(undefined, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`; `${currentWeekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
} }
async function loadCalendar() { async function loadCalendar() {
@ -156,6 +92,7 @@ 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);
@ -166,8 +103,9 @@ 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 ? t('schedule.hour_12am') : h < 12 ? h + t('schedule.hour_am') : h === 12 ? t('schedule.hour_12pm') : (h - 12) + t('schedule.hour_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 ? '12am' : h < 12 ? h + 'am' : h === 12 ? '12pm' : (h - 12) + '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>`;
} }
@ -175,6 +113,7 @@ 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);
@ -186,17 +125,12 @@ 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`;
${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.6);' : ''}`; block.textContent = ev.title || ev.content_name || ev.widget_name || 'Scheduled';
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);
}); });
@ -204,9 +138,7 @@ export async function render(container) {
function editSchedule(ev) { function editSchedule(ev) {
editingId = ev.id; editingId = ev.id;
document.getElementById('schedModalTitle').textContent = t('schedule.edit_schedule'); document.getElementById('schedModalTitle').textContent = '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);
@ -216,66 +148,26 @@ 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 = t('schedule.add_schedule'); document.getElementById('schedModalTitle').textContent = '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 isGroup = groupRadio.checked; const deviceId = document.getElementById('schedDevice').value;
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 = {
content_id: contentId || null, device_id: deviceId,
playlist_id: playlistId || null, content_id: contentId,
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`,
@ -284,12 +176,6 @@ 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) });
@ -297,7 +183,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(t('schedule.toast.saved'), 'success'); showToast('Schedule 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