Initial open source release

ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-08 12:14:53 -05:00
commit 1594a9d4a4
123 changed files with 22542 additions and 0 deletions

42
.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# Dependencies
node_modules/
# Database
server/db/*.db
server/db/*.db-wal
server/db/*.db-shm
# Uploads (user content)
server/uploads/
# Secrets and certificates
server/certs/key.pem
server/certs/cert.pem
server/certs/license_private.pem
server/certs/.jwt_secret
server/certs/.license_key
# Android
android/.gradle/
android/build/
android/app/build/
android/local.properties
android/release-key.jks
*.apk
*.aab
# IDE / Editor
.claude/
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.*

53
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,53 @@
# Contributing to ScreenTinker
Thanks for your interest in contributing! Here's how to get started.
## Getting Started
1. Fork the repository
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/screentinker.git`
3. Install dependencies: `cd server && npm install`
4. Start the dev server: `npm run dev`
5. Open `http://localhost:3001`
## Making Changes
1. Create a branch: `git checkout -b my-feature`
2. Make your changes
3. Test locally — make sure the server starts and the feature works
4. Commit with a clear message describing what changed and why
5. Push and open a pull request
## What to Contribute
- Bug fixes
- New widget types for the content designer
- Device platform support (e.g., new player implementations)
- Documentation improvements
- Translations (see `frontend/js/i18n.js`)
- Performance improvements
## Guidelines
- Keep PRs focused — one feature or fix per PR
- No build step for the frontend — it's vanilla JS by design
- Don't add heavy frameworks or dependencies without discussion
- Follow the existing code style
- Test on at least one device type if changing player/device code
## Reporting Issues
Open an issue on GitHub with:
- What you expected to happen
- What actually happened
- Steps to reproduce
- Browser/device/OS info if relevant
## Security
If you discover a security vulnerability, please email **support@screentinker.com** instead of opening a public issue.
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ScreenTinker
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

283
README.md Normal file
View file

@ -0,0 +1,283 @@
# ScreenTinker
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.
## Features
- **Multi-zone layouts** — split screens into zones with drag-and-drop editor
- **Video walls** — combine multiple displays into one screen with bezel compensation
- **Remote control** — live view, key input, power on/off
- **Scheduling** — visual weekly calendar with recurrence rules
- **Content designer** — clocks, weather, RSS tickers, countdowns, QR codes
- **Kiosk mode** — interactive touchscreen interfaces
- **Proof-of-play** — analytics and CSV export for ad verification
- **Alerts** — email notifications when devices go offline
- **Teams** — multi-user with owner, editor, and viewer roles
- **White-label** — custom branding, colors, logo, domain
- **Built-in billing** — Stripe integration for SaaS subscriptions (optional)
- **Auto-update** — OTA updates pushed to devices automatically
## Supported Platforms
Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, and any device with a web browser.
## Self-Hosting
### Requirements
- Node.js 20+
- Linux, macOS, or Windows
### Quick Start
```bash
git clone https://github.com/screentinker/screentinker.git
cd screentinker/server
npm install
SELF_HOSTED=true node server.js
```
The server starts on port 3001. Open `http://localhost:3001` in your browser. The first registered user gets full access with all features unlocked.
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | HTTP port | `3001` |
| `SELF_HOSTED` | First user gets all features unlocked | `false` |
| `APP_URL` | Your public URL (used for Stripe callbacks) | _(none)_ |
| `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ |
| `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` |
| `SSL_KEY` | Path to SSL private key | `server/certs/key.pem` |
### Optional Integrations
All integrations are optional. The app works fully without any of them.
#### Stripe (Billing)
If you want to charge your users, plug in your own Stripe keys. Without them, all features are free for all users.
1. Create a [Stripe account](https://stripe.com)
2. Create products/prices for each plan in the Stripe dashboard
3. Set up a webhook endpoint pointing to `https://yourdomain.com/api/stripe/webhook` with these events:
- `checkout.session.completed`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_failed`
4. Update the `plans` table in the SQLite DB with your Stripe price IDs:
```sql
UPDATE plans SET stripe_price_monthly = 'price_xxx', stripe_price_yearly = 'price_yyy' WHERE id = 'starter';
```
5. Set the environment variables:
| Variable | Description |
|----------|-------------|
| `STRIPE_SECRET_KEY` | Your Stripe secret key (`sk_live_...` or `sk_test_...`) |
| `STRIPE_WEBHOOK_SECRET` | Webhook signing secret (`whsec_...`) |
| `APP_URL` | Your public URL (e.g. `https://signage.yourcompany.com`) |
The default plans are: Free (1 device), Starter ($39/mo, 5 devices), Pro ($99/mo, 15 devices), Business ($199/mo, 50 devices), and Custom (unlimited). Edit the `plans` table to change pricing, limits, or add/remove tiers.
#### Google OAuth
Let users sign in with Google.
1. Create a project in [Google Cloud Console](https://console.cloud.google.com)
2. Enable the Google Identity API
3. Create OAuth 2.0 credentials (web application)
4. Add `https://yourdomain.com` as an authorized origin
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Your Google OAuth client ID |
#### Microsoft OAuth
Let users sign in with Microsoft/Azure AD.
1. Register an app in [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps)
2. Add a web redirect URI: `https://yourdomain.com`
3. Note the Application (client) ID
| Variable | Description |
|----------|-------------|
| `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID |
| `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) |
#### Email Alerts
Send email notifications when devices go offline.
| Variable | Description |
|----------|-------------|
| `EMAIL_WEBHOOK_URL` | POST endpoint that sends emails. Receives JSON: `{ to, subject, body }` |
You can point this at any email sending service (SendGrid, Mailgun, a simple SMTP relay, etc.) via a small webhook adapter.
### Production Deployment
For production, put the app behind a reverse proxy (nginx, Caddy, etc.) with SSL:
```bash
# Create a dedicated user
sudo useradd -r -s /bin/false screentinker
# Copy the app
sudo cp -r . /opt/screentinker
sudo chown -R screentinker:screentinker /opt/screentinker
# Install dependencies
cd /opt/screentinker/server && npm install --production
# Create a systemd service
sudo cat > /etc/systemd/system/screentinker.service << 'EOF'
[Unit]
Description=ScreenTinker
After=network.target
[Service]
Type=simple
User=screentinker
WorkingDirectory=/opt/screentinker/server
ExecStart=/usr/bin/node server.js
Restart=always
Environment=PORT=3001
Environment=NODE_ENV=production
Environment=SELF_HOSTED=true
# Environment=APP_URL=https://signage.yourcompany.com
# Environment=STRIPE_SECRET_KEY=sk_live_...
# Environment=STRIPE_WEBHOOK_SECRET=whsec_...
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable --now screentinker
```
#### Nginx Example
```nginx
server {
listen 80;
server_name signage.yourcompany.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name signage.yourcompany.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
client_max_body_size 500M;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
}
```
### Backups
The SQLite database is at `server/db/remote_display.db`. Back it up regularly:
```bash
# Safe backup (works even while the server is running)
sqlite3 server/db/remote_display.db ".backup /path/to/backup.db"
```
Uploaded content is in `server/uploads/`. Back that up too.
### Admin Recovery
Locked out? Run this on the server to get a temporary admin token (1 hour):
```bash
node scripts/reset-admin.js
```
### Building the Android APK
The Android player app is in the `android/` directory. To build it:
```bash
cd android
# Set your keystore credentials (or generate a new keystore)
export KEYSTORE_PASSWORD=your_password
export KEY_ALIAS=your_alias
export KEY_PASSWORD=your_password
# Build the APK
./gradlew assembleDebug
```
The APK will be at `android/app/build/outputs/apk/debug/app-debug.apk`. Copy it to `server/` as `ScreenTinker.apk` to serve it from `/download/apk`:
```bash
cp android/app/build/outputs/apk/debug/app-debug.apk ScreenTinker.apk
```
To generate a new signing keystore:
```bash
keytool -genkey -v -keystore android/release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias your_alias
```
**Requirements:** Java 17+, Android SDK (API 34).
### Device Setup
1. Register at your ScreenTinker instance
2. Go to **Displays** and click **Add Display**
3. Install the ScreenTinker app on your device:
- **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`
- **Windows**: Run the setup script from `scripts/windows-setup.bat`
- **Any browser**: Open `https://your-instance/player` in kiosk/fullscreen mode
4. Enter the pairing code shown on the device
## Project Structure
```
server/ Node.js/Express backend
config.js Configuration and environment variables
server.js Main entry point
db/ SQLite database and schema
routes/ API route handlers
middleware/ Auth, rate limiting, file upload
services/ Background services (heartbeat, scheduler, alerts)
ws/ WebSocket handlers (device + dashboard)
player/ Web-based display player
frontend/ Static SPA dashboard
js/views/ View components
css/ Stylesheets
legal/ Terms, privacy, licenses
android/ Android TV/tablet player app
scripts/ Device setup scripts + admin recovery
```
## Tech Stack
- **Backend:** Node.js, Express, Socket.IO, SQLite (better-sqlite3)
- **Frontend:** Vanilla JS SPA (no framework, no build step)
- **Android:** Kotlin, ExoPlayer, Socket.IO client
- **Auth:** JWT with bcrypt, Google/Microsoft OAuth (optional)
- **Payments:** Stripe (optional)
## License
[MIT](LICENSE)

1
VERSION Normal file
View file

@ -0,0 +1 @@
1.7.7

View file

@ -0,0 +1,78 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.remotedisplay.player"
compileSdk = 34
defaultConfig {
applicationId = "com.remotedisplay.player"
minSdk = 26
targetSdk = 34
versionCode = 10
versionName = "1.7.7"
}
signingConfigs {
create("release") {
storeFile = file("../release-key.jks")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: findProperty("KEYSTORE_PASSWORD") as String? ?: ""
keyAlias = System.getenv("KEY_ALIAS") ?: findProperty("KEY_ALIAS") as String? ?: "remotedisplay"
keyPassword = System.getenv("KEY_PASSWORD") ?: findProperty("KEY_PASSWORD") as String? ?: ""
}
}
buildTypes {
debug {
signingConfig = signingConfigs.getByName("release")
}
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// AndroidX
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-service:2.7.0")
// Encrypted SharedPreferences
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// ExoPlayer / Media3
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1")
// Socket.IO client
implementation("io.socket:socket.io-client:2.1.0")
// WorkManager for background downloads
implementation("androidx.work:work-runtime-ktx:2.9.0")
// Gson for JSON
implementation("com.google.code.gson:gson:2.10.1")
// OkHttp for file downloads
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}

3
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,3 @@
# Socket.IO
-keep class io.socket.** { *; }
-keep class okhttp3.** { *; }

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".RemoteDisplayApp"
android:allowBackup="true"
android:icon="@android:drawable/ic_media_play"
android:label="RemoteDisplay"
android:theme="@style/Theme.RemoteDisplay"
android:usesCleartextTraffic="true"
android:supportsRtl="true">
<!-- Main fullscreen media player activity -->
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:screenOrientation="landscape"
android:theme="@style/Theme.RemoteDisplay.Fullscreen"
android:keepScreenOn="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Screen capture permission request (transparent) -->
<activity
android:name=".ScreenCapturePermissionActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:screenOrientation="landscape" />
<!-- Initial setup wizard (permissions) -->
<activity
android:name=".SetupActivity"
android:exported="false"
android:screenOrientation="landscape"
android:theme="@style/Theme.RemoteDisplay.Fullscreen" />
<!-- Provisioning/setup activity -->
<activity
android:name=".ProvisioningActivity"
android:exported="false"
android:screenOrientation="landscape"
android:theme="@style/Theme.RemoteDisplay.Fullscreen" />
<!-- WebSocket foreground service -->
<service
android:name=".service.WebSocketService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|mediaProjection" />
<!-- Accessibility service for power controls -->
<service
android:name=".service.PowerAccessibilityService"
android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
<!-- Boot receiver for auto-start -->
<receiver
android:name=".service.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<!-- FileProvider for APK updates -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,553 @@
package com.remotedisplay.player
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Bundle
import android.widget.FrameLayout
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.media3.ui.PlayerView
import com.remotedisplay.player.data.ContentCache
import com.remotedisplay.player.data.ServerConfig
import com.remotedisplay.player.player.MediaPlayerManager
import com.remotedisplay.player.player.PlaylistController
import com.remotedisplay.player.player.PlaylistItem
import com.remotedisplay.player.player.ZoneManager
import com.remotedisplay.player.remote.ScreenshotCapture
import com.remotedisplay.player.remote.TouchInjector
import com.remotedisplay.player.service.UpdateChecker
import com.remotedisplay.player.service.WebSocketService
import org.json.JSONObject
import kotlin.concurrent.thread
class MainActivity : AppCompatActivity() {
private lateinit var config: ServerConfig
private lateinit var contentCache: ContentCache
private lateinit var screenshotCapture: ScreenshotCapture
private lateinit var touchInjector: TouchInjector
private var wsService: WebSocketService? = null
private var bound = false
private lateinit var mediaPlayer: MediaPlayerManager
private lateinit var playlistController: PlaylistController
private lateinit var updateChecker: UpdateChecker
private var zoneManager: ZoneManager? = null
private lateinit var playerView: PlayerView
private lateinit var imageView: ImageView
private lateinit var statusOverlay: View
private lateinit var statusText: TextView
private lateinit var rootView: View
private val handler = Handler(Looper.getMainLooper())
private var remoteStreaming = false
private var screenshotStreamRunnable: Runnable? = null
private var playbackStarted = false
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as WebSocketService.LocalBinder
wsService = binder.getService()
bound = true
setupServiceCallbacks()
wsService?.connect()
}
override fun onServiceDisconnected(name: ComponentName?) {
wsService = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
config = ServerConfig(this)
val prefs = getSharedPreferences("remote_display", MODE_PRIVATE)
// Show setup wizard if not completed yet
if (!prefs.getBoolean("setup_complete", false)) {
// Auto-mark complete if accessibility is already enabled (existing install)
if (isAccessibilityEnabled()) {
prefs.edit().putBoolean("setup_complete", true).apply()
} else {
startActivity(Intent(this, SetupActivity::class.java))
finish()
return
}
}
// Check provisioning BEFORE inflating the heavy media layout
if (!config.isProvisioned || !config.isPaired) {
startActivity(Intent(this, ProvisioningActivity::class.java))
finish()
return
}
setContentView(R.layout.activity_main)
// Fullscreen immersive
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
contentCache = ContentCache(this)
screenshotCapture = ScreenshotCapture()
touchInjector = TouchInjector()
playerView = findViewById(R.id.playerView)
imageView = findViewById(R.id.imageView)
statusOverlay = findViewById(R.id.statusOverlay)
statusText = findViewById(R.id.statusText)
rootView = findViewById(R.id.rootLayout)
// Hide player controls
playerView.useController = false
// Setup zone manager for multi-zone layouts
zoneManager = ZoneManager(this, rootView as FrameLayout) {
playlistController.onVideoComplete()
}
// Setup playlist controller
playlistController = PlaylistController(
onItemChanged = { item -> item?.let { playItem(it) } },
onPlaylistEmpty = { showStatus("Waiting for content...") },
onRequestRefresh = { wsService?.requestPlaylistRefresh() }
)
// Setup media player
val youtubeWebView = findViewById<android.webkit.WebView>(R.id.youtubeWebView)
mediaPlayer = MediaPlayerManager(
context = this,
playerView = playerView,
imageView = imageView,
youtubeWebView = youtubeWebView,
onVideoComplete = { playlistController.onVideoComplete() }
)
showStatus("Connecting to server...")
// Start and bind to WebSocket service
try {
val serviceIntent = Intent(this, WebSocketService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE)
} catch (e: Exception) {
Log.e("MainActivity", "Failed to start service: ${e.message}")
showStatus("Service error: ${e.message}")
}
// Start auto-update checker
updateChecker = UpdateChecker(this)
updateChecker.startPeriodicCheck()
}
private fun setupServiceCallbacks() {
wsService?.onPlaylistUpdate = { data ->
try {
// Check if device is suspended (trial expired / over limit)
if (data.optBoolean("suspended", false)) {
val message = data.optString("message", "Account Suspended")
val detail = data.optString("detail", "Please upgrade your plan.")
handler.post {
showStatus("$message\n$detail")
if (::mediaPlayer.isInitialized) mediaPlayer.stop()
}
} else {
val assignments = data.getJSONArray("assignments")
// Check for multi-zone layout
val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout")
val layoutZones = layoutObj?.optJSONArray("zones")
if (layoutZones != null && layoutZones.length() > 1) {
// Multi-zone mode - use ZoneManager
val layoutId = layoutObj?.optString("id", "") ?: ""
val currentLayoutId = zoneManager?.currentLayoutId
// Build a signature of current assignments to detect content changes
val assignmentSig = (0 until assignments.length()).map { i ->
val a = assignments.getJSONObject(i)
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}"
}.sorted().joinToString("|")
val changed = assignmentSig != zoneManager?.lastAssignmentSig
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) {
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
handler.post {
hideStatus()
if (::mediaPlayer.isInitialized) mediaPlayer.stop()
playlistController.stop()
playerView.visibility = View.GONE
imageView.visibility = View.GONE
zoneManager?.setupZones(layoutZones, layoutId)
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache)
zoneManager?.lastAssignmentSig = assignmentSig
}
} else if (changed) {
Log.i("MainActivity", "Multi-zone assignments changed, re-rendering")
handler.post {
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache)
zoneManager?.lastAssignmentSig = assignmentSig
}
} else {
Log.i("MainActivity", "Multi-zone unchanged, skipping")
}
} else {
// Single-zone mode - use PlaylistController (existing behavior)
if (zoneManager?.hasZones() == true) handler.post { zoneManager?.cleanup() }
playlistController.updatePlaylist(assignments)
}
// Download any missing local content (skip remote URLs)
thread {
for (i in 0 until assignments.length()) {
val item = assignments.getJSONObject(i)
val contentId = item.getString("content_id")
val filename = item.optString("filename", "content")
val remoteUrl = item.optString("remote_url", null)
// Skip remote URL content - it streams directly
if (!remoteUrl.isNullOrEmpty()) {
wsService?.sendContentAck(contentId, "ready")
continue
}
if (!contentCache.isContentCached(contentId)) {
Log.i("MainActivity", "Downloading content: $filename")
var downloaded = false
for (attempt in 1..3) {
val file = contentCache.downloadContent(config.serverUrl, contentId, filename)
if (file != null) {
wsService?.sendContentAck(contentId, "ready")
downloaded = true
break
}
Log.w("MainActivity", "Download attempt $attempt failed for $filename")
if (attempt < 3) Thread.sleep(2000L * attempt)
}
if (!downloaded) wsService?.sendContentAck(contentId, "failed")
}
}
// Start or resume playback after downloads complete
handler.post {
playlistController.startIfNeeded()
}
}
} // end else (not suspended)
} catch (e: Exception) {
Log.e("MainActivity", "Playlist update error: ${e.message}")
}
}
wsService?.onContentDelete = { contentId ->
contentCache.deleteContent(contentId)
playlistController.removeContent(contentId)
}
wsService?.onScreenshotRequest = {
// Handled by service now
}
wsService?.onRemoteStart = {
// Handled by service now
}
// Provide screenshot callback to service (composite capture on main thread)
wsService?.onCaptureScreenshot = {
screenshotCapture.captureView(rootView, 40)
}
wsService?.onRemoteStop = {
remoteStreaming = false
stopScreenshotStreaming()
}
wsService?.onRemoteTouch = { x, y, action ->
when (action) {
"tap" -> touchInjector.injectTap(rootView, x, y)
"down" -> touchInjector.injectDown(rootView, x, y)
"move" -> touchInjector.injectMove(rootView, x, y)
"up" -> touchInjector.injectUp(rootView, x, y)
}
}
wsService?.onRemoteKey = { _ ->
// Key injection handled in WebSocketService directly
}
wsService?.onCommand = { type, payload ->
Log.i("MainActivity", "Command received: $type")
when (type) {
"reboot", "shutdown", "power_menu" -> {
val svc = com.remotedisplay.player.service.PowerAccessibilityService.instance
if (svc != null) {
svc.showPowerDialog()
Log.i("MainActivity", "Power dialog shown via accessibility")
} else {
Log.w("MainActivity", "Accessibility service not enabled - trying fallback")
thread {
try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "--longpress", "26")).waitFor() } catch (_: Exception) {}
}
}
}
"screen_off" -> {
thread {
try {
Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() // POWER key
} catch (e: Exception) {
Log.e("MainActivity", "Screen off failed: ${e.message}")
}
}
}
"screen_on" -> {
thread {
try {
Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() // WAKEUP key
} catch (e: Exception) {
Log.e("MainActivity", "Screen on failed: ${e.message}")
}
}
}
"launch" -> {
val intent = android.content.Intent(this@MainActivity, MainActivity::class.java).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
startActivity(intent)
}
"update" -> {
Log.i("MainActivity", "Force update check triggered")
if (::updateChecker.isInitialized) updateChecker.checkForUpdate()
}
"refresh" -> {
wsService?.connect()
}
}
}
wsService?.onRegistered = { _ ->
hideStatus()
}
wsService?.onUnpaired = {
Log.w("MainActivity", "Device removed from server, going to provisioning")
handler.post {
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
})
finish()
}
}
}
private fun playItem(item: PlaylistItem) {
hideStatus()
// YouTube content - play in WebView
if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) {
Log.i("MainActivity", "Playing YouTube: ${item.remoteUrl}")
mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec)
wsService?.sendPlaybackState(item.contentId, 0f)
return
}
// Remote URL content - stream directly, no download
if (item.isRemote) {
Log.i("MainActivity", "Playing remote content: ${item.remoteUrl}")
if (item.mimeType.startsWith("video/")) {
mediaPlayer.playVideoFromUrl(item.remoteUrl!!, item.muted)
} else if (item.mimeType.startsWith("image/")) {
mediaPlayer.showImageFromUrl(item.remoteUrl!!)
}
wsService?.sendPlaybackState(item.contentId, 0f)
return
}
// Local content - download if not cached
val file = contentCache.getCachedFile(item.contentId)
if (file == null) {
Log.w("MainActivity", "Content not cached: ${item.contentId}, downloading...")
showStatus("Downloading ${item.filename}...")
thread {
val downloaded = contentCache.downloadContent(config.serverUrl, item.contentId, item.filename)
handler.post {
if (downloaded != null) {
playFile(item, downloaded)
} else {
showStatus("Download failed: ${item.filename}")
handler.postDelayed({ playlistController.next() }, 3000)
}
}
}
return
}
playFile(item, file)
}
private fun playFile(item: PlaylistItem, file: java.io.File) {
if (item.mimeType.startsWith("video/")) {
mediaPlayer.playVideo(file, item.muted)
} else if (item.mimeType.startsWith("image/")) {
mediaPlayer.showImage(file)
}
// Report playback state
wsService?.sendPlaybackState(item.contentId, 0f)
}
private fun showStatus(message: String) {
statusOverlay.visibility = View.VISIBLE
statusText.text = message
}
private fun hideStatus() {
statusOverlay.visibility = View.GONE
}
private fun captureAndSendScreenshot() {
Log.i("MainActivity", "Capturing screenshot")
val base64 = screenshotCapture.captureView(rootView, 40)
if (base64 != null) {
Log.i("MainActivity", "Screenshot captured, size=${base64.length} chars, sending...")
wsService?.sendScreenshot(base64)
} else {
Log.e("MainActivity", "Screenshot capture returned null!")
}
}
private fun startScreenshotStreaming() {
stopScreenshotStreaming()
screenshotStreamRunnable = object : Runnable {
override fun run() {
if (remoteStreaming) {
captureAndSendScreenshot()
handler.postDelayed(this, 1000) // ~1 FPS
}
}
}
handler.post(screenshotStreamRunnable!!)
}
private fun stopScreenshotStreaming() {
screenshotStreamRunnable?.let { handler.removeCallbacks(it) }
screenshotStreamRunnable = null
}
private fun handleRemoteKey(keycode: String) {
// Use shell `input keyevent` for system keys (HOME, BACK, etc.)
// This works from the app process on most Android TV devices
thread {
try {
val code = when (keycode) {
"KEYCODE_HOME" -> "3"
"KEYCODE_BACK" -> "4"
"KEYCODE_MENU" -> "82"
"KEYCODE_VOLUME_UP" -> "24"
"KEYCODE_VOLUME_DOWN" -> "25"
"KEYCODE_DPAD_UP" -> "19"
"KEYCODE_DPAD_DOWN" -> "20"
"KEYCODE_DPAD_LEFT" -> "21"
"KEYCODE_DPAD_RIGHT" -> "22"
"KEYCODE_DPAD_CENTER" -> "23"
"KEYCODE_ENTER" -> "66"
"KEYCODE_POWER" -> "26"
else -> return@thread
}
Log.i("MainActivity", "Injecting key: $keycode ($code)")
val process = Runtime.getRuntime().exec(arrayOf("input", "keyevent", code))
process.waitFor()
Log.i("MainActivity", "Key injection result: ${process.exitValue()}")
} catch (e: Exception) {
Log.e("MainActivity", "Key injection failed: ${e.message}")
}
}
}
@Suppress("DEPRECATION")
override fun onBackPressed() {
// Don't exit the app on back press - this is a kiosk/signage app
Log.i("MainActivity", "Back press intercepted (kiosk mode)")
}
private fun isAccessibilityEnabled(): Boolean {
val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val myComponent = ComponentName(this, com.remotedisplay.player.service.PowerAccessibilityService::class.java)
return am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK).any {
it.resolveInfo.serviceInfo.let { si -> ComponentName(si.packageName, si.name) == myComponent }
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// Home press brings us back - just re-apply immersive mode
Log.i("MainActivity", "onNewIntent - returning to foreground")
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
}
override fun onDestroy() {
remoteStreaming = false
zoneManager?.cleanup()
if (::mediaPlayer.isInitialized) {
stopScreenshotStreaming()
mediaPlayer.release()
}
if (bound) {
try { unbindService(connection) } catch (_: Exception) {}
bound = false
}
super.onDestroy()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
}
}
}

View file

@ -0,0 +1,164 @@
package com.remotedisplay.player
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.EditText
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.remotedisplay.player.data.ServerConfig
import com.remotedisplay.player.service.WebSocketService
class ProvisioningActivity : AppCompatActivity() {
private lateinit var config: ServerConfig
private var wsService: WebSocketService? = null
private var bound = false
private lateinit var serverUrlInput: EditText
private lateinit var connectBtn: Button
private lateinit var pairingCodeText: TextView
private lateinit var statusText: TextView
private lateinit var progressBar: ProgressBar
private lateinit var pairingSection: View
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as WebSocketService.LocalBinder
wsService = binder.getService()
bound = true
setupServiceCallbacks()
}
override fun onServiceDisconnected(name: ComponentName?) {
wsService = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_provisioning)
// Fullscreen immersive
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
config = ServerConfig(this)
serverUrlInput = findViewById(R.id.serverUrlInput)
connectBtn = findViewById(R.id.connectBtn)
pairingCodeText = findViewById(R.id.pairingCodeText)
statusText = findViewById(R.id.statusText)
progressBar = findViewById(R.id.progressBar)
pairingSection = findViewById(R.id.pairingSection)
// Pre-fill if previously entered
if (config.serverUrl.isNotEmpty()) {
serverUrlInput.setText(config.serverUrl)
}
connectBtn.setOnClickListener {
val url = serverUrlInput.text.toString().trim().trimEnd('/')
if (url.isEmpty()) {
statusText.text = "Please enter the server URL"
return@setOnClickListener
}
config.serverUrl = url
connectToServer(url)
}
// Request notification permission on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100)
} else {
startWebSocketService()
}
} else {
startWebSocketService()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
// Start service regardless of permission result - it just won't show notification on 13+
startWebSocketService()
}
private fun startWebSocketService() {
try {
val serviceIntent = Intent(this, WebSocketService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE)
} catch (e: Exception) {
Log.e("ProvisioningActivity", "Failed to start service: ${e.message}")
statusText.text = "Service error: ${e.message}"
}
}
private fun connectToServer(url: String) {
connectBtn.isEnabled = false
progressBar.visibility = View.VISIBLE
statusText.text = "Connecting to server..."
wsService?.connect(url)
}
private fun setupServiceCallbacks() {
wsService?.onRegistered = { deviceId ->
runOnUiThread {
progressBar.visibility = View.GONE
pairingSection.visibility = View.VISIBLE
pairingCodeText.text = wsService?.getPairingCode() ?: "------"
statusText.text = "Enter this code in the dashboard to pair this display"
connectBtn.isEnabled = false
}
}
wsService?.onPaired = { deviceId, name ->
runOnUiThread {
statusText.text = "Paired as: $name"
// Transition to main activity
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
}
}
}
override fun onDestroy() {
if (bound) {
unbindService(connection)
bound = false
}
super.onDestroy()
}
}

View file

@ -0,0 +1,34 @@
package com.remotedisplay.player
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
class RemoteDisplayApp : Application() {
companion object {
const val CHANNEL_ID = "remote_display_service"
const val CHANNEL_NAME = "ScreenTinker Service"
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "ScreenTinker background service"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
}

View file

@ -0,0 +1,64 @@
package com.remotedisplay.player
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import com.remotedisplay.player.service.ScreenCaptureService
/**
* Transparent activity that requests MediaProjection permission.
* Shows a system dialog asking "Start recording?" - user taps "Start now" once.
*/
class ScreenCapturePermissionActivity : Activity() {
companion object {
private const val REQUEST_CODE = 1001
private const val TAG = "ScreenCapturePermission"
// Store the result intent so the service can use it
var resultCode: Int = RESULT_CANCELED
private set
var resultData: Intent? = null
private set
var hasPermission = false
private set
fun requestPermission(context: Context) {
val intent = Intent(context, ScreenCapturePermissionActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val mediaProjectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), REQUEST_CODE)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE) {
if (resultCode == RESULT_OK && data != null) {
Log.i(TAG, "MediaProjection permission granted, starting via service")
// Store the result so the service can create the projection
Companion.resultCode = resultCode
Companion.resultData = data?.clone() as? Intent
Companion.hasPermission = true
// Tell the service to start the projection
ScreenCaptureService.startProjection(this, resultCode, data)
getSharedPreferences("remote_display", MODE_PRIVATE)
.edit().putBoolean("screen_capture_granted", true).apply()
} else {
Log.w(TAG, "MediaProjection permission denied")
}
}
finish()
}
}

View file

@ -0,0 +1,158 @@
package com.remotedisplay.player
import android.Manifest
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.View
import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.remotedisplay.player.service.PowerAccessibilityService
class SetupActivity : AppCompatActivity() {
private lateinit var accessibilityStatus: TextView
private lateinit var installStatus: TextView
private lateinit var notificationStatus: TextView
private lateinit var enableAccessibilityBtn: Button
private lateinit var enableInstallBtn: Button
private lateinit var continueBtn: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Skip setup if already completed
val prefs = getSharedPreferences("remote_display", MODE_PRIVATE)
if (prefs.getBoolean("setup_complete", false)) {
proceedToNext()
return
}
setContentView(R.layout.activity_setup)
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
accessibilityStatus = findViewById(R.id.accessibilityStatus)
installStatus = findViewById(R.id.installStatus)
notificationStatus = findViewById(R.id.notificationStatus)
enableAccessibilityBtn = findViewById(R.id.enableAccessibilityBtn)
enableInstallBtn = findViewById(R.id.enableInstallBtn)
continueBtn = findViewById(R.id.continueBtn)
// Show notification row on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
findViewById<View>(R.id.notificationRow).visibility = View.VISIBLE
findViewById<Button>(R.id.enableNotificationBtn).setOnClickListener {
ActivityCompat.requestPermissions(
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100
)
}
}
enableAccessibilityBtn.setOnClickListener {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
}
enableInstallBtn.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = android.net.Uri.parse("package:$packageName")
})
}
}
continueBtn.setOnClickListener {
prefs.edit().putBoolean("setup_complete", true).apply()
proceedToNext()
}
findViewById<TextView>(R.id.skipText).setOnClickListener {
prefs.edit().putBoolean("setup_complete", true).apply()
proceedToNext()
}
updateStatuses()
}
override fun onResume() {
super.onResume()
updateStatuses()
}
private fun updateStatuses() {
// Accessibility
val accessibilityEnabled = isAccessibilityEnabled()
accessibilityStatus.text = if (accessibilityEnabled) "ON" else "OFF"
accessibilityStatus.setTextColor(
if (accessibilityEnabled) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
)
enableAccessibilityBtn.visibility = if (accessibilityEnabled) View.GONE else View.VISIBLE
// Install unknown apps
val canInstall = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
packageManager.canRequestPackageInstalls()
} else true
installStatus.text = if (canInstall) "ON" else "OFF"
installStatus.setTextColor(
if (canInstall) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
)
enableInstallBtn.visibility = if (canInstall) View.GONE else View.VISIBLE
// Notifications (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val hasNotif = ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
notificationStatus.text = if (hasNotif) "ON" else "OFF"
notificationStatus.setTextColor(
if (hasNotif) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
)
findViewById<Button>(R.id.enableNotificationBtn).visibility =
if (hasNotif) View.GONE else View.VISIBLE
}
// Update continue button text
val allGood = accessibilityEnabled && canInstall
continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway"
}
private fun isAccessibilityEnabled(): Boolean {
val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
val myComponent = ComponentName(this, PowerAccessibilityService::class.java)
return enabledServices.any {
it.resolveInfo.serviceInfo.let { si ->
ComponentName(si.packageName, si.name) == myComponent
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
updateStatuses()
}
private fun proceedToNext() {
startActivity(Intent(this, ProvisioningActivity::class.java))
finish()
}
}

View file

@ -0,0 +1,68 @@
package com.remotedisplay.player.data
import android.content.Context
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
class ContentCache(private val context: Context) {
private val cacheDir = File(context.filesDir, "content_cache").also { it.mkdirs() }
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.MINUTES)
.build()
fun getCachedFile(contentId: String): File? {
val files = cacheDir.listFiles { _, name -> name.startsWith(contentId) }
return files?.firstOrNull()?.takeIf { it.exists() && it.length() > 0 }
}
fun isContentCached(contentId: String): Boolean {
return getCachedFile(contentId) != null
}
fun downloadContent(serverUrl: String, contentId: String, filename: String): File? {
try {
val url = "${serverUrl}/api/content/${contentId}/file"
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Log.e("ContentCache", "Download failed: ${response.code}")
return null
}
val ext = filename.substringAfterLast('.', "mp4")
val file = File(cacheDir, "${contentId}.${ext}")
response.body?.byteStream()?.use { input ->
FileOutputStream(file).use { output ->
input.copyTo(output)
}
}
Log.i("ContentCache", "Downloaded: $filename -> ${file.absolutePath}")
return file
} catch (e: Exception) {
Log.e("ContentCache", "Download error: ${e.message}")
return null
}
}
fun deleteContent(contentId: String) {
cacheDir.listFiles { _, name -> name.startsWith(contentId) }?.forEach { it.delete() }
Log.i("ContentCache", "Deleted cached content: $contentId")
}
fun clearAll() {
cacheDir.listFiles()?.forEach { it.delete() }
}
fun getCacheSize(): Long {
return cacheDir.listFiles()?.sumOf { it.length() } ?: 0L
}
}

View file

@ -0,0 +1,53 @@
package com.remotedisplay.player.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class ServerConfig(context: Context) {
private val prefs: SharedPreferences = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"remote_display_secure",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
// Fallback to regular prefs if encryption not available
Log.w("ServerConfig", "EncryptedSharedPreferences unavailable, using regular: ${e.message}")
context.getSharedPreferences("remote_display", Context.MODE_PRIVATE)
}
var serverUrl: String
get() = prefs.getString("server_url", "") ?: ""
set(value) = prefs.edit().putString("server_url", value).apply()
var deviceId: String
get() = prefs.getString("device_id", "") ?: ""
set(value) = prefs.edit().putString("device_id", value).apply()
var deviceName: String
get() = prefs.getString("device_name", "Unnamed Display") ?: "Unnamed Display"
set(value) = prefs.edit().putString("device_name", value).apply()
val isProvisioned: Boolean
get() = deviceId.isNotEmpty() && serverUrl.isNotEmpty()
val isPaired: Boolean
get() = prefs.getBoolean("is_paired", false)
fun setPaired(paired: Boolean) {
prefs.edit().putBoolean("is_paired", paired).apply()
}
fun clear() {
prefs.edit().clear().apply()
}
}

View file

@ -0,0 +1,166 @@
package com.remotedisplay.player.player
import android.content.Context
import android.net.Uri
import android.util.Log
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.ImageView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import java.io.File
class MediaPlayerManager(
private val context: Context,
private val playerView: PlayerView,
private val imageView: ImageView,
private val youtubeWebView: WebView? = null,
private val onVideoComplete: () -> Unit
) {
private var exoPlayer: ExoPlayer? = null
private var currentType: MediaType = MediaType.NONE
enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE }
init {
setupExoPlayer()
}
private fun setupExoPlayer() {
exoPlayer = ExoPlayer.Builder(context).build().also { player ->
playerView.player = player
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
onVideoComplete()
}
}
})
}
}
fun playYoutube(embedUrl: String, durationSec: Int = 0) {
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl")
currentType = MediaType.YOUTUBE
playerView.visibility = android.view.View.GONE
imageView.visibility = android.view.View.GONE
youtubeWebView?.visibility = android.view.View.VISIBLE
exoPlayer?.stop()
youtubeWebView?.apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
webViewClient = WebViewClient()
webChromeClient = WebChromeClient()
setBackgroundColor(android.graphics.Color.BLACK)
loadUrl(embedUrl)
}
}
fun playVideoFromUrl(url: String, muted: Boolean = false) {
Log.i("MediaPlayerManager", "Streaming video from URL: $url (muted=$muted)")
currentType = MediaType.VIDEO
playerView.visibility = android.view.View.VISIBLE
imageView.visibility = android.view.View.GONE
youtubeWebView?.visibility = android.view.View.GONE
exoPlayer?.apply {
volume = if (muted) 0f else 1f
setMediaItem(MediaItem.fromUri(Uri.parse(url)))
prepare()
playWhenReady = true
}
}
fun showImageFromUrl(url: String) {
Log.i("MediaPlayerManager", "Loading remote image: $url")
currentType = MediaType.IMAGE
playerView.visibility = android.view.View.GONE
imageView.visibility = android.view.View.VISIBLE
youtubeWebView?.visibility = android.view.View.GONE
exoPlayer?.stop()
// Load image from URL in background
Thread {
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) {
imageView.post { imageView.setImageBitmap(bitmap) }
}
} catch (e: Exception) {
Log.e("MediaPlayerManager", "Remote image load failed: ${e.message}")
}
}.start()
}
fun playVideo(file: File, muted: Boolean = false) {
Log.i("MediaPlayerManager", "Playing video: ${file.absolutePath} (muted=$muted)")
currentType = MediaType.VIDEO
// Show player, hide image
playerView.visibility = android.view.View.VISIBLE
imageView.visibility = android.view.View.GONE
youtubeWebView?.visibility = android.view.View.GONE
exoPlayer?.apply {
volume = if (muted) 0f else 1f
setMediaItem(MediaItem.fromUri(Uri.fromFile(file)))
prepare()
playWhenReady = true
}
}
fun showImage(file: File) {
Log.i("MediaPlayerManager", "Showing image: ${file.absolutePath}")
currentType = MediaType.IMAGE
// Show image, hide player
playerView.visibility = android.view.View.GONE
imageView.visibility = android.view.View.VISIBLE
youtubeWebView?.visibility = android.view.View.GONE
// Stop video if playing
exoPlayer?.stop()
// Load image
try {
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
if (bitmap != null) {
imageView.setImageBitmap(bitmap)
} else {
Log.e("MediaPlayerManager", "Failed to decode image: ${file.absolutePath}")
}
} catch (e: Exception) {
Log.e("MediaPlayerManager", "Error loading image: ${e.message}")
}
}
fun stop() {
exoPlayer?.stop()
imageView.setImageBitmap(null)
youtubeWebView?.loadUrl("about:blank")
youtubeWebView?.visibility = android.view.View.GONE
currentType = MediaType.NONE
}
fun release() {
exoPlayer?.release()
exoPlayer = null
}
fun isPlayingVideo(): Boolean = currentType == MediaType.VIDEO && (exoPlayer?.isPlaying == true)
}

View file

@ -0,0 +1,188 @@
package com.remotedisplay.player.player
import android.os.Handler
import android.os.Looper
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
data class PlaylistItem(
val assignmentId: Int,
val contentId: String,
val filename: String,
val mimeType: String,
val filepath: String,
val durationSec: Int,
val fileSize: Long,
val sortOrder: Int,
val enabled: Boolean = true,
val remoteUrl: String? = null,
val muted: Boolean = false
) {
val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty()
}
class PlaylistController(
private val onItemChanged: (PlaylistItem?) -> Unit,
private val onPlaylistEmpty: () -> Unit,
private val onRequestRefresh: (() -> Unit)? = null
) {
private val items = mutableListOf<PlaylistItem>()
private var currentIndex = -1
private val handler = Handler(Looper.getMainLooper())
private var advanceRunnable: Runnable? = null
private var isRunning = false
val isPlaying: Boolean get() = isRunning && currentIndex >= 0
val currentItem: PlaylistItem?
get() = if (currentIndex in items.indices) items[currentIndex] else null
val currentContentId: String?
get() = currentItem?.contentId
fun updatePlaylist(assignmentsJson: JSONArray) {
Log.i("PlaylistController", "Received JSONArray with ${assignmentsJson.length()} items")
// Build new list
val newItems = mutableListOf<PlaylistItem>()
for (i in 0 until assignmentsJson.length()) {
val obj = assignmentsJson.getJSONObject(i)
newItems.add(
PlaylistItem(
assignmentId = obj.optInt("id", 0),
contentId = obj.getString("content_id"),
filename = obj.optString("filename", "unknown"),
mimeType = obj.optString("mime_type", "video/mp4"),
filepath = obj.optString("filepath", ""),
durationSec = obj.optInt("duration_sec", 10),
fileSize = obj.optLong("file_size", 0),
sortOrder = obj.optInt("sort_order", 0),
enabled = obj.optInt("enabled", 1) == 1,
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
muted = obj.optInt("muted", 0) == 1
)
)
}
// Check if playlist actually changed
val oldContentIds = items.map { it.contentId }
val newContentIds = newItems.map { it.contentId }
val playlistChanged = oldContentIds != newContentIds
if (!playlistChanged && items.isNotEmpty()) {
Log.i("PlaylistController", "Playlist unchanged (${items.size} items), not interrupting playback")
return
}
Log.i("PlaylistController", "Playlist changed: ${items.size} -> ${newItems.size} items")
// Remember what's currently playing
val currentlyPlayingId = currentItem?.contentId
items.clear()
items.addAll(newItems)
if (items.isEmpty()) {
currentIndex = -1
cancelAdvance()
onPlaylistEmpty()
} else if (isRunning) {
// Try to keep playing the current item if it's still in the list
if (currentlyPlayingId != null) {
val newIndex = items.indexOfFirst { it.contentId == currentlyPlayingId }
if (newIndex >= 0) {
// Current item still exists - don't interrupt, just update index
currentIndex = newIndex
Log.i("PlaylistController", "Current item still in playlist at index $newIndex, not interrupting")
return
}
}
// Current item was removed or nothing was playing - start from beginning
currentIndex = 0
playCurrentItem()
} else {
currentIndex = 0
}
}
fun removeContent(contentId: String) {
val wasCurrentId = currentItem?.contentId
items.removeAll { it.contentId == contentId }
if (items.isEmpty()) {
currentIndex = -1
cancelAdvance()
onPlaylistEmpty()
} else if (wasCurrentId == contentId) {
if (currentIndex >= items.size) currentIndex = 0
playCurrentItem()
}
}
fun start() {
isRunning = true
if (items.isNotEmpty()) {
if (currentIndex < 0) currentIndex = 0
playCurrentItem()
} else {
onPlaylistEmpty()
}
}
fun startIfNeeded() {
if (items.isEmpty()) {
Log.i("PlaylistController", "No items, nothing to start")
onPlaylistEmpty()
return
}
if (isRunning && currentIndex >= 0 && currentIndex < items.size) {
// Already playing something valid - don't restart
Log.i("PlaylistController", "Already playing ${items[currentIndex].filename}, not restarting")
return
}
Log.i("PlaylistController", "Starting playback")
start()
}
fun stop() {
isRunning = false
cancelAdvance()
}
fun next() {
if (items.isEmpty()) return
currentIndex = (currentIndex + 1) % items.size
// Request a playlist refresh between plays so new content gets picked up
onRequestRefresh?.invoke()
playCurrentItem()
}
fun onVideoComplete() {
// Called when a video finishes naturally
next()
}
private fun playCurrentItem() {
cancelAdvance()
val item = currentItem ?: return
Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)")
onItemChanged(item)
// For images, auto-advance after duration. For videos, wait for completion callback.
if (item.mimeType.startsWith("image/")) {
scheduleAdvance(item.durationSec * 1000L)
}
}
private fun scheduleAdvance(delayMs: Long) {
cancelAdvance()
advanceRunnable = Runnable { next() }
handler.postDelayed(advanceRunnable!!, delayMs)
}
private fun cancelAdvance() {
advanceRunnable?.let { handler.removeCallbacks(it) }
advanceRunnable = null
}
}

View file

@ -0,0 +1,234 @@
package com.remotedisplay.player.player
import android.content.Context
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
data class Zone(
val id: String,
val name: String,
val xPercent: Float,
val yPercent: Float,
val widthPercent: Float,
val heightPercent: Float,
val zIndex: Int,
val zoneType: String,
val fitMode: String
)
class ZoneManager(
private val context: Context,
private val container: FrameLayout,
private val onAllVideosComplete: () -> Unit
) {
private val TAG = "ZoneManager"
private val zoneViews = mutableMapOf<String, View>()
private val zoneExoPlayers = mutableMapOf<String, ExoPlayer>()
private var zones = listOf<Zone>()
private var activeVideoCount = 0
private var completedVideoCount = 0
var currentLayoutId: String? = null
private set
var lastAssignmentSig: String? = null
fun hasZones(): Boolean = zones.isNotEmpty()
fun setupZones(zonesJson: JSONArray, layoutId: String? = null) {
currentLayoutId = layoutId
cleanup()
zones = (0 until zonesJson.length()).map { i ->
val z = zonesJson.getJSONObject(i)
Zone(
id = z.getString("id"),
name = z.optString("name", "Zone"),
xPercent = z.optDouble("x_percent", 0.0).toFloat(),
yPercent = z.optDouble("y_percent", 0.0).toFloat(),
widthPercent = z.optDouble("width_percent", 100.0).toFloat(),
heightPercent = z.optDouble("height_percent", 100.0).toFloat(),
zIndex = z.optInt("z_index", 0),
zoneType = z.optString("zone_type", "content"),
fitMode = z.optString("fit_mode", "cover")
)
}
Log.i(TAG, "Setup ${zones.size} zones")
}
fun renderAssignments(assignments: JSONArray, serverUrl: String, contentCache: com.remotedisplay.player.data.ContentCache) {
// Clear existing zone views
container.removeAllViews()
zoneViews.clear()
releaseExoPlayers()
activeVideoCount = 0
completedVideoCount = 0
val containerWidth = container.width
val containerHeight = container.height
if (containerWidth == 0 || containerHeight == 0) {
// Container not laid out yet, post delayed
container.post { renderAssignments(assignments, serverUrl, contentCache) }
return
}
// Map assignments by zone_id
val assignmentsByZone = mutableMapOf<String?, MutableList<JSONObject>>()
for (i in 0 until assignments.length()) {
val a = assignments.getJSONObject(i)
val zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null)
assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a)
}
// 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
for (zone in zones.sortedBy { it.zIndex }) {
val zoneAssignments: List<JSONObject> = assignmentsByZone[zone.id]
?: if (!unassignedUsed) { unassignedUsed = true; assignmentsByZone[null] ?: emptyList() } else emptyList()
val firstAssignment = zoneAssignments.firstOrNull() ?: continue
// Calculate pixel position
val x = (zone.xPercent / 100f * containerWidth).toInt()
val y = (zone.yPercent / 100f * containerHeight).toInt()
val w = (zone.widthPercent / 100f * containerWidth).toInt()
val h = (zone.heightPercent / 100f * containerHeight).toInt()
val params = FrameLayout.LayoutParams(w, h).apply {
leftMargin = x
topMargin = y
}
val mimeType = firstAssignment.optString("mime_type", "")
val remoteUrl = if (firstAssignment.isNull("remote_url")) null else firstAssignment.optString("remote_url", null)
val widgetType = if (firstAssignment.isNull("widget_type")) null else firstAssignment.optString("widget_type", null)
val widgetConfig = if (firstAssignment.isNull("widget_config")) null else firstAssignment.optString("widget_config", null)
val contentId = if (firstAssignment.isNull("content_id")) null else firstAssignment.optString("content_id", null)
val filepath = firstAssignment.optString("filepath", "")
val isMuted = firstAssignment.optInt("muted", 0) == 1
when {
// Widget - render in WebView
widgetType != null -> {
val widgetId = firstAssignment.optString("widget_id", "")
val webView = createWebView()
webView.loadUrl("$serverUrl/api/widgets/$widgetId/render")
webView.layoutParams = params
container.addView(webView)
zoneViews[zone.id] = webView
Log.i(TAG, "Zone ${zone.name}: widget $widgetType")
}
// YouTube - render in WebView
mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> {
val webView = createWebView()
webView.loadUrl(remoteUrl)
webView.layoutParams = params
container.addView(webView)
zoneViews[zone.id] = webView
Log.i(TAG, "Zone ${zone.name}: youtube $remoteUrl")
}
// Video
mimeType.startsWith("video/") -> {
val src = if (!remoteUrl.isNullOrEmpty()) remoteUrl
else if (contentId != null) contentCache.getCachedFile(contentId)?.let { Uri.fromFile(it).toString() }
?: "$serverUrl/uploads/content/$filepath"
else continue
val playerView = (android.view.LayoutInflater.from(context)
.inflate(com.remotedisplay.player.R.layout.zone_player, null) as PlayerView).apply {
useController = false
layoutParams = params
}
val exoPlayer = ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(src))
repeatMode = Player.REPEAT_MODE_ALL
// Use muted flag from assignment, default unmuted for first video
volume = if (isMuted) 0f else 1f
prepare()
playWhenReady = true
}
playerView.player = exoPlayer
container.addView(playerView)
zoneViews[zone.id] = playerView
zoneExoPlayers[zone.id] = exoPlayer
activeVideoCount++
Log.i(TAG, "Zone ${zone.name}: video $src")
}
// Image
mimeType.startsWith("image/") -> {
val imageView = ImageView(context).apply {
scaleType = when (zone.fitMode) {
"contain" -> ImageView.ScaleType.FIT_CENTER
"fill" -> ImageView.ScaleType.FIT_XY
else -> ImageView.ScaleType.CENTER_CROP
}
layoutParams = params
}
// Load image
val file = contentId?.let { contentCache.getCachedFile(it) }
if (file != null) {
val bitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
if (bitmap != null) imageView.setImageBitmap(bitmap)
} else if (!remoteUrl.isNullOrEmpty()) {
// Load from URL in background
Thread {
try {
val connection = java.net.URL(remoteUrl).openConnection()
val input = connection.getInputStream()
val bitmap = android.graphics.BitmapFactory.decodeStream(input)
input.close()
imageView.post { if (bitmap != null) imageView.setImageBitmap(bitmap) }
} catch (e: Exception) {
Log.e(TAG, "Image load failed: ${e.message}")
}
}.start()
}
container.addView(imageView)
zoneViews[zone.id] = imageView
Log.i(TAG, "Zone ${zone.name}: image")
}
}
}
Log.i(TAG, "Rendered ${zoneViews.size} zone views")
}
private fun createWebView(): WebView {
return WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
setBackgroundColor(android.graphics.Color.TRANSPARENT)
webViewClient = WebViewClient()
}
}
private fun releaseExoPlayers() {
zoneExoPlayers.values.forEach { it.release() }
zoneExoPlayers.clear()
}
fun cleanup() {
releaseExoPlayers()
container.removeAllViews()
zoneViews.clear()
zones = listOf()
}
}

View file

@ -0,0 +1,122 @@
package com.remotedisplay.player.remote
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import java.io.ByteArrayOutputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class ScreenshotCapture {
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Capture the entire view hierarchy including video content.
* Thread-safe: marshals to main thread if needed.
*/
fun captureView(view: View, quality: Int = 40): String? {
return if (Looper.myLooper() == Looper.getMainLooper()) {
captureOnMainThread(view, quality)
} else {
val latch = CountDownLatch(1)
var result: String? = null
mainHandler.post {
result = captureOnMainThread(view, quality)
latch.countDown()
}
latch.await(3, TimeUnit.SECONDS)
result
}
}
/**
* Must be called on main thread.
* Draws the view hierarchy + composites TextureView bitmap for video.
*/
private fun captureOnMainThread(view: View, quality: Int): String? {
return try {
val w = view.width
val h = view.height
if (w <= 0 || h <= 0) {
Log.w("ScreenshotCapture", "View has no size: ${w}x${h}")
return null
}
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
// First draw the view hierarchy (gets UI elements, images, overlays)
// Note: view.draw() renders TextureView areas as black since video
// is in a separate hardware surface
view.draw(canvas)
// Then composite TextureView content (video) ON TOP
// This replaces the black areas where video should be
val textureViews = mutableListOf<TextureView>()
findAllTextureViews(view, textureViews)
for (tv in textureViews) {
if (tv.isAvailable && tv.visibility == View.VISIBLE) {
val tvBitmap = tv.bitmap
if (tvBitmap != null) {
val loc = IntArray(2)
tv.getLocationInWindow(loc)
val rootLoc = IntArray(2)
view.getLocationInWindow(rootLoc)
val x = (loc[0] - rootLoc[0]).toFloat()
val y = (loc[1] - rootLoc[1]).toFloat()
val destRect = Rect(x.toInt(), y.toInt(), x.toInt() + tv.width, y.toInt() + tv.height)
canvas.drawBitmap(tvBitmap, null, destRect, null)
tvBitmap.recycle()
Log.d("ScreenshotCapture", "Composited TextureView at ($x,$y) size=${tv.width}x${tv.height}")
}
}
}
Log.i("ScreenshotCapture", "Composite capture: ${w}x${h}, ${textureViews.size} TextureView(s)")
encodeBitmap(bitmap, quality)
} catch (e: Exception) {
Log.e("ScreenshotCapture", "Capture failed: ${e.message}", e)
null
}
}
private fun encodeBitmap(bitmap: Bitmap, quality: Int): String {
val toEncode = if (bitmap.width > 960) {
val scale = 960f / bitmap.width
val h = (bitmap.height * scale).toInt()
val scaled = Bitmap.createScaledBitmap(bitmap, 960, h, true)
if (scaled !== bitmap) bitmap.recycle()
scaled
} else {
bitmap
}
val stream = ByteArrayOutputStream()
toEncode.compress(Bitmap.CompressFormat.JPEG, quality, stream)
val w = toEncode.width
val h = toEncode.height
toEncode.recycle()
val result = Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
Log.i("ScreenshotCapture", "Encoded ${w}x${h}, size=${result.length} chars")
return result
}
private fun findAllTextureViews(view: View, result: MutableList<TextureView>) {
if (view is TextureView) {
result.add(view)
return
}
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findAllTextureViews(view.getChildAt(i), result)
}
}
}
}

View file

@ -0,0 +1,48 @@
package com.remotedisplay.player.remote
import android.util.Log
import android.view.View
class TouchInjector {
/**
* Injects a tap at normalized coordinates (0.0 to 1.0) using shell `input tap`.
* Works system-wide - can interact with system dialogs, other apps, etc.
*/
fun injectTap(view: View, normalizedX: Float, normalizedY: Float) {
val metrics = view.context.resources.displayMetrics
val screenW = metrics.widthPixels
val screenH = metrics.heightPixels
val x = (normalizedX * screenW).toInt()
val y = (normalizedY * screenH).toInt()
Log.i("TouchInjector", "Tap at ($x, $y) from normalized ($normalizedX, $normalizedY) screen=${screenW}x${screenH}")
Thread {
try {
Runtime.getRuntime().exec(arrayOf("input", "tap", "$x", "$y")).waitFor()
} catch (e: Exception) {
Log.e("TouchInjector", "Tap injection failed: ${e.message}")
}
}.start()
}
fun injectDown(view: View, normalizedX: Float, normalizedY: Float) {
val metrics = view.context.resources.displayMetrics
val x = (normalizedX * metrics.widthPixels).toInt()
val y = (normalizedY * metrics.heightPixels).toInt()
Thread {
try {
Runtime.getRuntime().exec(arrayOf("input", "swipe", "$x", "$y", "$x", "$y", "2000")).waitFor()
} catch (e: Exception) {
Log.e("TouchInjector", "Touch down failed: ${e.message}")
}
}.start()
}
fun injectMove(view: View, normalizedX: Float, normalizedY: Float) {
// Shell input doesn't support continuous move well - swipe is the closest
}
fun injectUp(view: View, normalizedX: Float, normalizedY: Float) {
// Shell input tap is atomic - up is handled by tap/swipe completion
}
}

View file

@ -0,0 +1,75 @@
package com.remotedisplay.player.service
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
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() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action == Intent.ACTION_BOOT_COMPLETED ||
action == "android.intent.action.QUICKBOOT_POWERON" ||
action == "com.htc.intent.action.QUICKBOOT_POWERON") {
Log.i("BootReceiver", "Boot completed (action=$action), launching ScreenTinker")
// Start the foreground service
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

@ -0,0 +1,122 @@
package com.remotedisplay.player.service
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.os.Build
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import android.view.accessibility.AccessibilityEvent
class PowerAccessibilityService : AccessibilityService() {
companion object {
var instance: PowerAccessibilityService? = null
private const val TAG = "AccessibilityService"
}
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
Log.i(TAG, "Service connected")
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
override fun onInterrupt() {}
// Global actions
fun showPowerDialog() {
Log.i(TAG, "Showing power dialog")
performGlobalAction(GLOBAL_ACTION_POWER_DIALOG)
}
fun pressHome() {
Log.i(TAG, "Home")
performGlobalAction(GLOBAL_ACTION_HOME)
}
fun pressBack() {
Log.i(TAG, "Back")
performGlobalAction(GLOBAL_ACTION_BACK)
}
fun openRecents() {
Log.i(TAG, "Recents")
performGlobalAction(GLOBAL_ACTION_RECENTS)
}
fun openNotifications() {
Log.i(TAG, "Notifications")
performGlobalAction(GLOBAL_ACTION_NOTIFICATIONS)
}
fun lockScreen() {
Log.i(TAG, "Lock screen")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN)
}
}
/**
* Inject a tap at normalized coordinates (0.0-1.0) using dispatchGesture.
* Works system-wide - can tap on system dialogs, other apps, etc.
*/
fun injectTap(normalizedX: Float, normalizedY: Float) {
val metrics = getScreenMetrics()
val x = normalizedX * metrics.widthPixels
val y = normalizedY * metrics.heightPixels
Log.i(TAG, "Tap at (${x.toInt()}, ${y.toInt()}) screen=${metrics.widthPixels}x${metrics.heightPixels}")
val path = Path().apply { moveTo(x, y) }
val stroke = GestureDescription.StrokeDescription(path, 0, 50)
val gesture = GestureDescription.Builder().addStroke(stroke).build()
dispatchGesture(gesture, null, null)
}
/**
* Inject a swipe gesture at normalized coordinates.
*/
fun injectSwipe(startX: Float, startY: Float, endX: Float, endY: Float, durationMs: Long = 300) {
val metrics = getScreenMetrics()
val sx = startX * metrics.widthPixels
val sy = startY * metrics.heightPixels
val ex = endX * metrics.widthPixels
val ey = endY * metrics.heightPixels
val path = Path().apply {
moveTo(sx, sy)
lineTo(ex, ey)
}
val stroke = GestureDescription.StrokeDescription(path, 0, durationMs)
val gesture = GestureDescription.Builder().addStroke(stroke).build()
dispatchGesture(gesture, null, null)
}
/**
* Inject a key event via shell command. Falls back gracefully.
*/
fun injectKey(keyCode: Int) {
Log.i(TAG, "Key: $keyCode")
Thread {
try {
Runtime.getRuntime().exec(arrayOf("input", "keyevent", "$keyCode")).waitFor()
} catch (e: Exception) {
Log.w(TAG, "Key inject failed: ${e.message}")
}
}.start()
}
private fun getScreenMetrics(): DisplayMetrics {
val wm = getSystemService(WINDOW_SERVICE) as WindowManager
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(metrics)
return metrics
}
override fun onDestroy() {
instance = null
super.onDestroy()
}
}

View file

@ -0,0 +1,130 @@
package com.remotedisplay.player.service
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.util.Base64
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import java.io.ByteArrayOutputStream
/**
* Manages MediaProjection for system-wide screenshot capture.
* Works even when our app is in the background.
*/
object ScreenCaptureService {
private const val TAG = "ScreenCapture"
private var mediaProjection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
val isReady: Boolean get() = mediaProjection != null && imageReader != null
/**
* Start the projection from a context that has a foreground service running.
*/
fun startProjection(context: Context, resultCode: Int, data: Intent) {
stop()
val manager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val projection = manager.getMediaProjection(resultCode, data)
if (projection == null) {
Log.e(TAG, "Failed to get MediaProjection")
return
}
mediaProjection = projection
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(metrics)
val captureWidth = 960
val captureHeight = (metrics.heightPixels * (960f / metrics.widthPixels)).toInt()
val density = metrics.densityDpi
imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4)
virtualDisplay = projection.createVirtualDisplay(
"ScreenTinker",
captureWidth, captureHeight, density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
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}")
}
/**
* Capture current screen as base64 JPEG.
*/
@Synchronized
fun captureScreen(quality: Int = 40): String? {
val reader = imageReader ?: return null
var image: android.media.Image? = null
return try {
image = reader.acquireLatestImage() ?: return null
val plane = image.planes[0]
val buffer = plane.buffer
val pixelStride = plane.pixelStride
val rowStride = plane.rowStride
val rowPadding = rowStride - pixelStride * image.width
val imgWidth = image.width
val imgHeight = image.height
val bitmapWidth = imgWidth + rowPadding / pixelStride
val bitmap = Bitmap.createBitmap(bitmapWidth, imgHeight, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(buffer)
image.close()
image = null
// Crop to actual width (remove row padding)
val cropped = if (bitmapWidth > imgWidth) {
val c = Bitmap.createBitmap(bitmap, 0, 0, imgWidth, imgHeight)
bitmap.recycle()
c
} else bitmap
val stream = ByteArrayOutputStream()
cropped.compress(Bitmap.CompressFormat.JPEG, quality, stream)
cropped.recycle()
Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
} catch (e: Exception) {
Log.e(TAG, "Capture failed: ${e.message}")
null
} finally {
try { image?.close() } catch (_: Exception) {}
}
}
private fun cleanup() {
virtualDisplay?.release()
virtualDisplay = null
imageReader?.close()
imageReader = null
}
fun stop() {
cleanup()
try { mediaProjection?.stop() } catch (_: Exception) {}
mediaProjection = null
}
}

View file

@ -0,0 +1,189 @@
package com.remotedisplay.player.service
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.FileProvider
import com.remotedisplay.player.data.ServerConfig
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.util.concurrent.TimeUnit
class UpdateChecker(private val context: Context) {
private val TAG = "UpdateChecker"
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
private val handler = Handler(Looper.getMainLooper())
private val config = ServerConfig(context)
private var checkTimer: Runnable? = null
// Check every 30 minutes
private val CHECK_INTERVAL = 30 * 60 * 1000L
fun startPeriodicCheck() {
stopPeriodicCheck()
checkTimer = object : Runnable {
override fun run() {
checkForUpdate()
handler.postDelayed(this, CHECK_INTERVAL)
}
}
// First check after 60 seconds (let the app settle)
handler.postDelayed(checkTimer!!, 60000)
Log.i(TAG, "Periodic update check started (every ${CHECK_INTERVAL / 60000}m)")
}
fun stopPeriodicCheck() {
checkTimer?.let { handler.removeCallbacks(it) }
checkTimer = null
}
fun checkForUpdate() {
if (config.serverUrl.isEmpty()) return
Thread {
try {
val currentVersion = getAppVersion()
val url = "${config.serverUrl}/api/update/check?version=$currentVersion"
Log.i(TAG, "Checking for updates: $url")
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Log.w(TAG, "Update check failed: ${response.code}")
return@Thread
}
val json = JSONObject(response.body?.string() ?: "{}")
val updateAvailable = json.optBoolean("update_available", false)
val latestVersion = json.optString("latest_version", currentVersion)
val downloadUrl = json.optString("download_url", "")
Log.i(TAG, "Current: $currentVersion, Latest: $latestVersion, Update: $updateAvailable")
if (updateAvailable && downloadUrl.isNotEmpty()) {
Log.i(TAG, "Update available! Downloading...")
downloadAndInstall("${config.serverUrl}$downloadUrl", latestVersion)
}
} catch (e: Exception) {
Log.e(TAG, "Update check error: ${e.message}")
}
}.start()
}
private fun downloadAndInstall(url: String, version: String) {
try {
// Download to a temp file
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Log.e(TAG, "Download failed: ${response.code}")
return
}
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
"ScreenTinker-$version.apk")
response.body?.byteStream()?.use { input ->
apkFile.outputStream().use { output ->
input.copyTo(output)
}
}
Log.i(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)")
// Install the APK
handler.post {
installApk(apkFile)
}
} catch (e: Exception) {
Log.e(TAG, "Download/install error: ${e.message}")
}
}
private fun installApk(apkFile: File) {
// Try silent session install first (no Play Protect dialog)
try {
tryPackageInstaller(apkFile)
return
} catch (e: Exception) {
Log.w(TAG, "Session install failed: ${e.message}, falling back to intent")
}
// Fallback: intent-based install (shows dialog)
try {
val intent = Intent(Intent.ACTION_VIEW)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
intent.setDataAndType(uri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} else {
intent.setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive")
}
context.startActivity(intent)
Log.i(TAG, "Install intent launched")
} catch (e: Exception) {
Log.e(TAG, "Install failed: ${e.message}")
}
}
private fun tryPackageInstaller(apkFile: File) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val installer = context.packageManager.packageInstaller
val params = android.content.pm.PackageInstaller.SessionParams(
android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
val sessionId = installer.createSession(params)
val session = installer.openSession(sessionId)
apkFile.inputStream().use { input ->
session.openWrite("ScreenTinker", 0, apkFile.length()).use { output ->
input.copyTo(output)
session.fsync(output)
}
}
val pendingIntent = android.app.PendingIntent.getBroadcast(
context, sessionId,
Intent("com.remotedisplay.player.INSTALL_COMPLETE"),
android.app.PendingIntent.FLAG_MUTABLE
)
session.commit(pendingIntent.intentSender)
Log.i(TAG, "Package installer session committed")
}
} catch (e: Exception) {
Log.e(TAG, "Package installer failed: ${e.message}")
}
}
private fun getAppVersion(): String {
return try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
} catch (e: Exception) {
"1.0.0"
}
}
}

View file

@ -0,0 +1,472 @@
package com.remotedisplay.player.service
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import com.remotedisplay.player.MainActivity
import com.remotedisplay.player.RemoteDisplayApp
import com.remotedisplay.player.data.ServerConfig
import com.remotedisplay.player.telemetry.DeviceInfo
import io.socket.client.IO
import io.socket.client.Socket
import org.json.JSONObject
import java.net.URI
class WebSocketService : Service() {
private var socket: Socket? = null
private lateinit var config: ServerConfig
private lateinit var deviceInfo: DeviceInfo
private val handler = Handler(Looper.getMainLooper())
private var heartbeatRunnable: Runnable? = null
private val binder = LocalBinder()
// Callbacks
var onPaired: ((String, String) -> Unit)? = null
var onUnpaired: (() -> Unit)? = null
var onRegistered: ((String) -> Unit)? = null
var onPlaylistUpdate: ((JSONObject) -> Unit)? = null
var onContentDelete: ((String) -> Unit)? = null
var onScreenshotRequest: (() -> Unit)? = null
var onRemoteStart: (() -> Unit)? = null
var onRemoteStop: (() -> Unit)? = null
var onRemoteTouch: ((Float, Float, String) -> Unit)? = null
var onRemoteKey: ((String) -> Unit)? = null
var onCommand: ((String, JSONObject?) -> Unit)? = null
inner class LocalBinder : Binder() {
fun getService(): WebSocketService = this@WebSocketService
}
override fun onBind(intent: Intent?): IBinder = binder
private var wakeLock: android.os.PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
config = ServerConfig(this)
deviceInfo = DeviceInfo(this)
startForeground(1, createNotification())
// Keep CPU alive so the WebSocket connection stays alive in background
val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager
wakeLock = pm.newWakeLock(android.os.PowerManager.PARTIAL_WAKE_LOCK, "RemoteDisplay:WebSocket")
wakeLock?.acquire()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
fun connect(serverUrl: String? = null) {
val url = serverUrl ?: config.serverUrl
if (url.isEmpty()) {
Log.e("WebSocketService", "No server URL configured")
return
}
disconnect()
try {
val options = IO.Options().apply {
forceNew = true
reconnection = true
reconnectionAttempts = Integer.MAX_VALUE
reconnectionDelay = 2000
reconnectionDelayMax = 10000
timeout = 20000
}
socket = IO.socket(URI.create("$url/device"), options).apply {
on(Socket.EVENT_CONNECT) {
Log.i("WebSocketService", "Connected to server")
register()
}
on(Socket.EVENT_DISCONNECT) {
Log.w("WebSocketService", "Disconnected from server")
}
on(Socket.EVENT_CONNECT_ERROR) { args ->
Log.e("WebSocketService", "Connection error: ${args.firstOrNull()}")
}
on("device:registered") { args ->
val data = args[0] as JSONObject
val newDeviceId = data.getString("device_id")
config.deviceId = newDeviceId
Log.i("WebSocketService", "Registered as: $newDeviceId")
handler.post { onRegistered?.invoke(newDeviceId) }
startHeartbeat()
}
on("device:unpaired") {
Log.w("WebSocketService", "Device not found on server - clearing config")
config.setPaired(false)
config.deviceId = ""
handler.post { onUnpaired?.invoke() }
}
on("device:paired") { args ->
val data = args[0] as JSONObject
val id = data.getString("device_id")
val name = data.optString("name", "Display")
config.setPaired(true)
config.deviceName = name
Log.i("WebSocketService", "Paired as: $name")
handler.post { onPaired?.invoke(id, name) }
}
on("device:playlist-update") { args ->
Log.i("WebSocketService", "Playlist raw args: ${args.size} items, type=${args[0]?.javaClass?.name}, data=${args[0]}")
val data = args[0] as JSONObject
Log.i("WebSocketService", "Playlist update received, keys=${data.keys().asSequence().toList()}, assignments=${data.optJSONArray("assignments")?.length() ?: "null"}")
handler.post { onPlaylistUpdate?.invoke(data) }
}
on("device:content-delete") { args ->
val data = args[0] as JSONObject
val contentId = data.getString("content_id")
handler.post { onContentDelete?.invoke(contentId) }
}
on("device:screenshot-request") {
captureAndSendScreenshot()
handler.post { onScreenshotRequest?.invoke() }
}
on("device:remote-start") {
startScreenshotStream()
handler.post { onRemoteStart?.invoke() }
}
on("device:remote-stop") {
stopScreenshotStream()
handler.post { onRemoteStop?.invoke() }
}
on("device:remote-touch") { args ->
val data = args[0] as JSONObject
val x = data.getDouble("x").toFloat()
val y = data.getDouble("y").toFloat()
val action = data.optString("action", "tap")
// Use AccessibilityService for system-wide touch (works on dialogs too)
val svc = PowerAccessibilityService.instance
if (svc != null && action == "tap") {
handler.post { svc.injectTap(x, y) }
} else {
handler.post { onRemoteTouch?.invoke(x, y, action) }
}
}
on("device:remote-key") { args ->
val data = args[0] as JSONObject
val keycode = data.getString("keycode")
// Always inject via shell (works even when app not in foreground)
injectKey(keycode)
handler.post { onRemoteKey?.invoke(keycode) }
}
on("device:command") { args ->
val data = args[0] as JSONObject
val type = data.getString("type")
val payload = data.optJSONObject("payload")
Log.i("WebSocketService", "Command received: $type")
// Handle system commands directly in the service
when (type) {
"launch" -> {
handler.post {
val intent = Intent(this@WebSocketService, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
startActivity(intent)
Log.i("WebSocketService", "Launched MainActivity from service")
}
}
"settings" -> {
handler.post {
val intent = Intent(android.provider.Settings.ACTION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
Log.i("WebSocketService", "Opened system settings")
}
}
"enable_system_capture" -> {
// Trigger MediaProjection permission request on device
handler.post {
com.remotedisplay.player.ScreenCapturePermissionActivity.requestPermission(this@WebSocketService)
Log.i("WebSocketService", "Requesting system capture permission")
}
}
"screen_off" -> {
val a11y = PowerAccessibilityService.instance
if (a11y != null) {
handler.post { a11y.lockScreen() }
} else {
Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() } catch (_: Exception) {} }.start()
}
}
"screen_on" -> {
// WAKEUP keyevent works from shell on most devices
Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() } catch (_: Exception) {} }.start()
}
else -> handler.post { onCommand?.invoke(type, payload) }
}
}
connect()
}
} catch (e: Exception) {
Log.e("WebSocketService", "Socket setup error: ${e.message}")
}
}
private fun register() {
val data = JSONObject().apply {
if (config.isProvisioned && config.isPaired) {
put("device_id", config.deviceId)
} else {
// Generate a pairing code if we don't have one
val pairingCode = (100000..999999).random().toString()
put("pairing_code", pairingCode)
config.deviceId = "" // Will be set on registered event
// Store pairing code temporarily
getSharedPreferences("remote_display", MODE_PRIVATE)
.edit().putString("pairing_code", pairingCode).apply()
}
put("device_info", deviceInfo.getDeviceInfo())
put("fingerprint", deviceInfo.getFingerprint())
}
socket?.emit("device:register", data)
}
fun getPairingCode(): String {
return getSharedPreferences("remote_display", MODE_PRIVATE)
.getString("pairing_code", "") ?: ""
}
private var heartbeatCount = 0
private fun startHeartbeat() {
stopHeartbeat()
heartbeatCount = 0
heartbeatRunnable = object : Runnable {
override fun run() {
sendHeartbeat()
heartbeatCount++
// Every 4th heartbeat (60s), request a fresh playlist
if (heartbeatCount % 4 == 0) {
requestPlaylistRefresh()
}
handler.postDelayed(this, 15000) // Every 15 seconds
}
}
handler.post(heartbeatRunnable!!)
}
fun requestPlaylistRefresh() {
if (socket?.connected() != true || config.deviceId.isEmpty()) return
Log.i("WebSocketService", "Requesting playlist refresh")
// Re-register triggers the server to send current playlist
val data = org.json.JSONObject().apply {
put("device_id", config.deviceId)
put("device_info", deviceInfo.getDeviceInfo())
}
socket?.emit("device:register", data)
}
private fun stopHeartbeat() {
heartbeatRunnable?.let { handler.removeCallbacks(it) }
heartbeatRunnable = null
}
private fun sendHeartbeat() {
if (socket?.connected() != true) return
val data = JSONObject().apply {
put("device_id", config.deviceId)
put("telemetry", deviceInfo.getTelemetry())
}
socket?.emit("device:heartbeat", data)
}
// Screenshot streaming from the service (works even when activity is paused)
private var streaming = false
private var streamRunnable: Runnable? = null
fun startScreenshotStream() {
stopScreenshotStream()
streaming = true
streamRunnable = Runnable { streamLoop() }
handler.post(streamRunnable!!)
Log.i("WebSocketService", "Screenshot streaming started")
}
private fun streamLoop() {
if (!streaming) { Log.w("WebSocketService", "streamLoop called but not streaming"); return }
Thread {
try {
val b64 = captureScreen()
if (b64 != null) {
sendScreenshot(b64)
Log.d("WebSocketService", "Screenshot streamed: ${b64.length} chars")
} else {
Log.w("WebSocketService", "Screenshot capture returned null")
}
} catch (e: Exception) {
Log.e("WebSocketService", "Stream error: ${e.message}")
}
if (streaming) handler.postDelayed(streamRunnable ?: return@Thread, 1000)
}.start()
}
fun stopScreenshotStream() {
streaming = false
streamRunnable?.let { handler.removeCallbacks(it) }
streamRunnable = null
Log.i("WebSocketService", "Screenshot streaming stopped")
}
// Callback for Activity to provide screenshot
var onCaptureScreenshot: (() -> String?)? = null
private fun captureScreen(): String? {
// Priority 1: MediaProjection (system-wide, works in background)
if (ScreenCaptureService.isReady) {
val result = ScreenCaptureService.captureScreen(40)
if (result != null) return result
}
// Priority 2: Activity callback (view-based, only when app is foreground)
val fromActivity = onCaptureScreenshot?.invoke()
if (fromActivity != null) return fromActivity
Log.w("WebSocketService", "No screenshot method available")
return null
}
fun captureAndSendScreenshot() {
Thread {
val b64 = captureScreen()
if (b64 != null) sendScreenshot(b64)
}.start()
}
fun sendScreenshot(imageBase64: String) {
if (socket?.connected() != true) return
val data = JSONObject().apply {
put("device_id", config.deviceId)
put("image_b64", imageBase64)
}
socket?.emit("device:screenshot", data)
}
private fun injectKey(keycode: String) {
val svc = PowerAccessibilityService.instance
// Use AccessibilityService global actions for system keys (works without INJECT_EVENTS)
if (svc != null) {
when (keycode) {
"KEYCODE_POWER" -> { handler.post { svc.showPowerDialog() }; return }
"KEYCODE_HOME" -> {
// Launch our activity instead of system Home (we ARE the launcher)
// This avoids creating duplicate instances
handler.post {
val intent = Intent(this@WebSocketService, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
startActivity(intent)
}
return
}
"KEYCODE_BACK" -> { handler.post { svc.pressBack() }; return }
"KEYCODE_APP_SWITCH" -> { handler.post { svc.openRecents() }; return }
}
}
// For other keys, use shell input keyevent (works for volume, d-pad on most devices)
val code = when (keycode) {
"KEYCODE_HOME" -> "3"
"KEYCODE_BACK" -> "4"
"KEYCODE_MENU" -> "82"
"KEYCODE_VOLUME_UP" -> "24"
"KEYCODE_VOLUME_DOWN" -> "25"
"KEYCODE_DPAD_UP" -> "19"
"KEYCODE_DPAD_DOWN" -> "20"
"KEYCODE_DPAD_LEFT" -> "21"
"KEYCODE_DPAD_RIGHT" -> "22"
"KEYCODE_DPAD_CENTER" -> "23"
"KEYCODE_ENTER" -> "66"
"KEYCODE_POWER" -> "26"
else -> return
}
Log.i("WebSocketService", "Injecting key: $keycode ($code)")
Thread {
try {
Runtime.getRuntime().exec(arrayOf("input", "keyevent", code)).waitFor()
} catch (e: Exception) {
Log.e("WebSocketService", "Key injection failed: ${e.message}")
}
}.start()
}
fun sendContentAck(contentId: String, status: String) {
if (socket?.connected() != true) return
val data = JSONObject().apply {
put("device_id", config.deviceId)
put("content_id", contentId)
put("status", status)
}
socket?.emit("device:content-ack", data)
}
fun sendPlaybackState(contentId: String, positionSec: Float) {
if (socket?.connected() != true) return
val data = JSONObject().apply {
put("device_id", config.deviceId)
put("current_content_id", contentId)
put("position_sec", positionSec)
}
socket?.emit("device:playback-state", data)
}
fun disconnect() {
stopHeartbeat()
socket?.disconnect()
socket?.off()
socket = null
}
fun isConnected(): Boolean = socket?.connected() == true
override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() }
disconnect()
super.onDestroy()
}
private fun createNotification(): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, RemoteDisplayApp.CHANNEL_ID)
.setContentTitle("ScreenTinker")
.setContentText("Display service is running")
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
}

View file

@ -0,0 +1,161 @@
package com.remotedisplay.player.telemetry
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.wifi.WifiManager
import android.os.BatteryManager
import android.os.Build
import android.os.Environment
import android.os.StatFs
import android.os.SystemClock
import android.provider.Settings
import android.util.DisplayMetrics
import android.view.WindowManager
import java.security.MessageDigest
import org.json.JSONObject
class DeviceInfo(private val context: Context) {
fun getTelemetry(): JSONObject {
return JSONObject().apply {
put("battery_level", getBatteryLevel())
put("battery_charging", isBatteryCharging())
put("storage_free_mb", getStorageFreeMB())
put("storage_total_mb", getStorageTotalMB())
put("ram_free_mb", getRamFreeMB())
put("ram_total_mb", getRamTotalMB())
put("cpu_usage", getCpuUsage())
put("wifi_ssid", getWifiSSID())
put("wifi_rssi", getWifiRSSI())
put("uptime_seconds", getUptimeSeconds())
}
}
fun getDeviceInfo(): JSONObject {
val display = getDisplayMetrics()
return JSONObject().apply {
put("android_version", Build.VERSION.RELEASE)
put("app_version", getAppVersion())
put("screen_width", display.widthPixels)
put("screen_height", display.heightPixels)
}
}
private fun getBatteryLevel(): Int {
// Use broadcast intent method - more reliable on Android TV / Rockchip devices
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
if (intent != null) {
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
if (level >= 0 && scale > 0) return (level * 100 / scale)
}
// Fallback to BatteryManager API
val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
}
private fun isBatteryCharging(): Boolean {
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
return status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
}
private fun getStorageFreeMB(): Long {
val stat = StatFs(Environment.getDataDirectory().path)
return stat.availableBytes / (1024 * 1024)
}
private fun getStorageTotalMB(): Long {
val stat = StatFs(Environment.getDataDirectory().path)
return stat.totalBytes / (1024 * 1024)
}
private fun getRamFreeMB(): Long {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memInfo = ActivityManager.MemoryInfo()
am.getMemoryInfo(memInfo)
return memInfo.availMem / (1024 * 1024)
}
private fun getRamTotalMB(): Long {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memInfo = ActivityManager.MemoryInfo()
am.getMemoryInfo(memInfo)
return memInfo.totalMem / (1024 * 1024)
}
private fun getCpuUsage(): Double {
// Simple estimation - in production you'd read /proc/stat
return try {
val runtime = Runtime.getRuntime()
val usedMem = runtime.totalMemory() - runtime.freeMemory()
val maxMem = runtime.maxMemory()
(usedMem.toDouble() / maxMem.toDouble()) * 100.0
} catch (e: Exception) {
0.0
}
}
@Suppress("DEPRECATION")
private fun getWifiSSID(): String {
return try {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val info = wm.connectionInfo
info.ssid?.replace("\"", "") ?: "Unknown"
} catch (e: Exception) {
"Unknown"
}
}
@Suppress("DEPRECATION")
private fun getWifiRSSI(): Int {
return try {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
wm.connectionInfo.rssi
} catch (e: Exception) {
0
}
}
private fun getUptimeSeconds(): Long {
return SystemClock.elapsedRealtime() / 1000
}
private fun getDisplayMetrics(): DisplayMetrics {
val dm = DisplayMetrics()
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(dm)
return dm
}
private fun getAppVersion(): String {
return try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
} catch (e: Exception) {
"1.0.0"
}
}
@Suppress("DEPRECATION", "HardwareIds")
fun getFingerprint(): String {
// Create a hardware fingerprint that survives app reinstalls
val parts = listOf(
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "",
Build.BOARD,
Build.BRAND,
Build.DEVICE,
Build.HARDWARE,
Build.MANUFACTURER,
Build.MODEL,
Build.PRODUCT,
try { Build.SERIAL } catch (e: Exception) { "unknown" },
Build.DISPLAY,
)
val raw = parts.joinToString("|")
val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray())
return digest.joinToString("") { "%02x".format(it) }
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#2563EB">
<item>
<shape android:shape="rectangle">
<solid android:color="#3B82F6" />
<corners android:radius="8dp" />
</shape>
</item>
</ripple>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0F172A" />
<stroke android:width="1dp" android:color="#334155" />
<corners android:radius="8dp" />
</shape>

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/rootLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000"
android:keepScreenOn="true">
<!-- ExoPlayer Video View -->
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:use_controller="false"
app:resize_mode="fit"
app:surface_type="texture_view"
android:visibility="gone" />
<!-- Image View for static images -->
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitCenter"
android:visibility="gone"
android:contentDescription="Display content" />
<!-- WebView for YouTube embeds -->
<WebView
android:id="@+id/youtubeWebView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Status Overlay -->
<LinearLayout
android:id="@+id/statusOverlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:background="#000000"
android:visibility="visible">
<ImageView
android:layout_width="80dp"
android:layout_height="80dp"
android:src="@android:drawable/ic_media_play"
android:layout_marginBottom="16dp"
android:alpha="0.3"
android:contentDescription="Logo" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="RemoteDisplay"
android:textColor="#3B82F6"
android:textSize="28sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Connecting..."
android:textColor="#94A3B8"
android:textSize="16sp" />
<ProgressBar
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="24dp"
android:indeterminate="true" />
</LinearLayout>
</FrameLayout>

View file

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:background="#111827"
android:padding="48dp"
android:keepScreenOn="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="RemoteDisplay"
android:textColor="#3B82F6"
android:textSize="36sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Digital Signage Player"
android:textColor="#94A3B8"
android:textSize="16sp"
android:layout_marginBottom="48dp" />
<!-- Server URL Section -->
<LinearLayout
android:layout_width="400dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Server URL"
android:textColor="#94A3B8"
android:textSize="14sp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/serverUrlInput"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@drawable/input_background"
android:hint="http://192.168.1.100:3000"
android:textColorHint="#64748B"
android:textColor="#F1F5F9"
android:textSize="16sp"
android:padding="12dp"
android:inputType="textUri"
android:singleLine="true"
android:importantForAutofill="no" />
</LinearLayout>
<Button
android:id="@+id/connectBtn"
android:layout_width="400dp"
android:layout_height="48dp"
android:text="Connect"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold"
android:background="@drawable/button_primary"
android:layout_marginBottom="32dp" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="48dp"
android:layout_height="48dp"
android:indeterminate="true"
android:visibility="gone" />
<!-- Pairing Section (shown after connection) -->
<LinearLayout
android:id="@+id/pairingSection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Pairing Code"
android:textColor="#94A3B8"
android:textSize="16sp"
android:layout_marginBottom="12dp" />
<TextView
android:id="@+id/pairingCodeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="------"
android:textColor="#3B82F6"
android:textSize="64sp"
android:textStyle="bold"
android:fontFamily="monospace"
android:letterSpacing="0.3" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enter this code in the dashboard to pair this display"
android:textColor="#64748B"
android:textSize="14sp"
android:layout_marginTop="16dp"
android:gravity="center" />
</LinearLayout>
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#94A3B8"
android:textSize="14sp"
android:layout_marginTop="16dp" />
</LinearLayout>

View file

@ -0,0 +1,215 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:background="#111827"
android:padding="48dp"
android:keepScreenOn="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="RemoteDisplay Setup"
android:textColor="#3B82F6"
android:textSize="32sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enable these permissions for full remote control"
android:textColor="#94A3B8"
android:textSize="16sp"
android:layout_marginBottom="40dp" />
<LinearLayout
android:layout_width="500dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="32dp">
<!-- Accessibility Service -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<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="Accessibility Service"
android:textColor="#F1F5F9"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Required for remote control (Home, Back, touch, gestures)"
android:textColor="#64748B"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:id="@+id/accessibilityStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableAccessibilityBtn"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:background="@drawable/button_primary"
android:paddingStart="20dp"
android:paddingEnd="20dp" />
</LinearLayout>
<!-- Display Over Other Apps -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<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="Install Unknown Apps"
android:textColor="#F1F5F9"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Required for automatic OTA updates"
android:textColor="#64748B"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:id="@+id/installStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableInstallBtn"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:background="@drawable/button_primary"
android:paddingStart="20dp"
android:paddingEnd="20dp" />
</LinearLayout>
<!-- Notification Permission (Android 13+) -->
<LinearLayout
android:id="@+id/notificationRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp"
android:visibility="gone">
<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="Notifications"
android:textColor="#F1F5F9"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Required for background service"
android:textColor="#64748B"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:id="@+id/notificationStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableNotificationBtn"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:background="@drawable/button_primary"
android:paddingStart="20dp"
android:paddingEnd="20dp" />
</LinearLayout>
</LinearLayout>
<Button
android:id="@+id/continueBtn"
android:layout_width="500dp"
android:layout_height="48dp"
android:text="Continue to Setup"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold"
android:background="@drawable/button_primary" />
<TextView
android:id="@+id/skipText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Skip (remote control features will be limited)"
android:textColor="#64748B"
android:textSize="13sp"
android:layout_marginTop="16dp"
android:padding="8dp" />
</LinearLayout>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.media3.ui.PlayerView
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_height="match_parent"
app:use_controller="false"
app:resize_mode="fit"
app:surface_type="texture_view" />

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#111827</color>
<color name="ic_launcher_foreground">#3B82F6</color>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string>
</resources>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.RemoteDisplay" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">#3B82F6</item>
<item name="colorPrimaryVariant">#2563EB</item>
<item name="colorSecondary">#1E293B</item>
<item name="android:windowBackground">#111827</item>
<item name="android:statusBarColor">#111827</item>
<item name="android:navigationBarColor">#111827</item>
</style>
<style name="Theme.RemoteDisplay.Fullscreen" parent="Theme.RemoteDisplay">
<item name="android:windowFullscreen">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="windowActionBar">false</item>
</style>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_description"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:notificationTimeout="100" />

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="downloads" path="Download/" />
<external-files-path name="apk" path="." />
</paths>

5
android/build.gradle.kts Normal file
View file

@ -0,0 +1,5 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
}

View file

@ -0,0 +1,3 @@
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
android/gradlew vendored Executable file
View file

@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "RemoteDisplay"
include(":app")

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

928
frontend/css/main.css Normal file
View file

@ -0,0 +1,928 @@
/* Layout */
body {
display: flex;
overflow: hidden;
}
.sidebar {
width: var(--sidebar-width);
height: 100vh;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
position: fixed;
left: 0;
top: 0;
z-index: 100;
}
.sidebar-header {
padding: 20px 16px;
border-bottom: 1px solid var(--border);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
color: var(--accent);
font-weight: 700;
font-size: 16px;
}
.nav-links {
flex: 1;
padding: 12px 8px;
}
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius);
color: var(--text-secondary);
transition: all var(--transition);
margin-bottom: 2px;
}
.nav-link:hover {
background: var(--bg-card);
color: var(--text-primary);
}
.nav-link.active {
background: var(--accent);
color: white;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border);
}
.connection-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.online { background: var(--success); box-shadow: 0 0 6px var(--success); }
.status-dot.offline { background: var(--danger); }
.status-dot.provisioning { background: var(--warning); animation: pulse 2s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.content {
margin-left: var(--sidebar-width);
flex: 1;
height: 100vh;
overflow-y: auto;
padding: 24px 32px;
}
/* Page Header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.page-header h1 {
font-size: 24px;
font-weight: 600;
}
.page-header .subtitle {
color: var(--text-secondary);
font-size: 13px;
margin-top: 2px;
}
/* Buttons */
.btn {
padding: 8px 16px;
border-radius: var(--radius);
font-weight: 500;
font-size: 13px;
transition: all var(--transition);
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--bg-card-hover);
}
.btn-danger {
background: var(--danger-dim);
color: #fca5a5;
}
.btn-danger:hover {
background: var(--danger);
color: white;
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
}
.btn-icon {
padding: 6px;
border-radius: var(--radius);
color: var(--text-secondary);
transition: all var(--transition);
}
.btn-icon:hover {
background: var(--bg-card);
color: var(--text-primary);
}
/* Device Grid */
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.device-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all var(--transition);
cursor: pointer;
}
.device-card:hover {
border-color: var(--accent);
box-shadow: var(--shadow);
transform: translateY(-2px);
}
.device-card-preview {
aspect-ratio: 16/9;
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
.device-card-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.device-card-preview .no-preview {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
gap: 8px;
}
.device-card-preview .no-preview svg {
opacity: 0.3;
}
.device-card-status {
position: absolute;
top: 10px;
right: 10px;
display: flex;
align-items: center;
gap: 6px;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
}
.device-card-body {
padding: 14px 16px;
}
.device-card-name {
font-weight: 600;
font-size: 15px;
margin-bottom: 6px;
}
.device-card-meta {
display: flex;
align-items: center;
gap: 12px;
color: var(--text-secondary);
font-size: 12px;
}
.device-card-meta .meta-item {
display: flex;
align-items: center;
gap: 4px;
}
/* Device Detail */
.device-detail {
max-width: 1200px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
margin-bottom: 16px;
font-size: 13px;
transition: color var(--transition);
}
.back-link:hover { color: var(--text-primary); }
.device-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.device-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.device-header-left h1 {
font-size: 22px;
}
.device-status-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.device-status-badge.online { background: var(--success-dim); color: var(--success); }
.device-status-badge.offline { background: var(--danger-dim); color: #fca5a5; }
.device-status-badge.provisioning { background: var(--warning-dim); color: var(--warning); }
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all var(--transition);
cursor: pointer;
}
.tab:hover { color: var(--text-primary); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Screenshot Preview */
.screenshot-container {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
aspect-ratio: 16/9;
position: relative;
margin-bottom: 20px;
}
.screenshot-container img {
width: 100%;
height: 100%;
object-fit: contain;
}
.screenshot-container .no-screenshot {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
gap: 8px;
}
/* Remote Control */
.remote-container {
display: flex;
gap: 20px;
}
.remote-screen {
flex: 1;
background: #000;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
position: relative;
cursor: crosshair;
}
.remote-screen canvas {
width: 100%;
display: block;
}
.remote-controls {
width: 120px;
display: flex;
flex-direction: column;
gap: 8px;
}
.remote-controls .btn {
width: 100%;
justify-content: center;
}
/* Playlist */
.playlist-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.playlist-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: all var(--transition);
}
.playlist-item:hover {
border-color: var(--border-light);
}
.playlist-item-thumb {
width: 80px;
height: 45px;
border-radius: 4px;
object-fit: cover;
background: var(--bg-primary);
flex-shrink: 0;
}
.playlist-item-info {
flex: 1;
min-width: 0;
}
.playlist-item-name {
font-weight: 500;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playlist-item-meta {
font-size: 11px;
color: var(--text-secondary);
}
.playlist-item-actions {
display: flex;
gap: 4px;
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.info-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.info-card-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 6px;
}
.info-card-value {
font-size: 20px;
font-weight: 600;
}
.info-card-value.small {
font-size: 14px;
}
/* Progress bar */
.progress-bar {
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
overflow: hidden;
margin-top: 8px;
}
.progress-bar-fill {
height: 100%;
border-radius: 3px;
transition: width var(--transition);
}
.progress-bar-fill.success { background: var(--success); }
.progress-bar-fill.warning { background: var(--warning); }
.progress-bar-fill.danger { background: var(--danger); }
/* Content Library */
.content-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.content-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: all var(--transition);
}
.content-item:hover {
border-color: var(--border-light);
}
.content-item-preview {
aspect-ratio: 16/9;
background: var(--bg-primary);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.content-item-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.content-item-preview .video-icon {
color: var(--text-muted);
}
.content-item-body {
padding: 10px 12px;
}
.content-item-name {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.content-item-size {
font-size: 11px;
color: var(--text-muted);
}
.content-item-actions {
display: flex;
justify-content: flex-end;
padding: 0 12px 10px;
gap: 4px;
}
/* Upload Area */
.upload-area {
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all var(--transition);
margin-bottom: 24px;
}
.upload-area:hover,
.upload-area.dragover {
border-color: var(--accent);
background: rgba(59, 130, 246, 0.05);
}
.upload-area svg {
margin: 0 auto 12px;
color: var(--text-muted);
}
.upload-area p {
color: var(--text-secondary);
font-size: 14px;
}
.upload-area .upload-hint {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 440px;
max-width: 90vw;
box-shadow: var(--shadow);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
font-size: 16px;
font-weight: 600;
}
.modal-body {
padding: 20px;
}
.modal-description {
color: var(--text-secondary);
font-size: 13px;
margin-bottom: 16px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border);
}
/* Form Elements */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.input {
width: 100%;
padding: 8px 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
transition: border-color var(--transition);
}
.input:focus {
outline: none;
border-color: var(--accent);
}
.pairing-input {
width: 100%;
padding: 12px 16px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
font-size: 28px;
font-weight: 700;
text-align: center;
letter-spacing: 8px;
font-family: 'Courier New', monospace;
}
.pairing-input:focus {
outline: none;
border-color: var(--accent);
}
/* Toast */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 2000;
}
.toast {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
min-width: 280px;
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 10px;
animation: slideIn 0.3s ease;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--danger); }
.toast.info { border-left: 3px solid var(--accent); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 24px;
color: var(--text-muted);
}
.empty-state svg {
margin: 0 auto 16px;
opacity: 0.3;
}
.empty-state h3 {
color: var(--text-secondary);
margin-bottom: 8px;
}
.empty-state p {
font-size: 13px;
max-width: 360px;
margin: 0 auto;
}
/* Settings */
.settings-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 20px;
}
.settings-section h3 {
font-size: 16px;
margin-bottom: 16px;
}
/* Assign Modal */
.assign-content-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
max-height: 400px;
overflow-y: auto;
}
.assign-content-item {
background: var(--bg-input);
border: 2px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: all var(--transition);
}
.assign-content-item:hover {
border-color: var(--accent);
}
.assign-content-item.selected {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.assign-content-item img {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
}
.assign-content-item-name {
padding: 6px 8px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-light);
}
/* Upload progress */
.upload-progress {
margin-top: 16px;
}
.upload-progress-bar {
height: 4px;
background: var(--bg-primary);
border-radius: 2px;
overflow: hidden;
}
.upload-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Help tooltips */
.help-tip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
cursor: help;
position: relative;
margin-left: 6px;
flex-shrink: 0;
}
.help-tip:hover::after {
content: attr(data-tip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 12px;
font-size: 12px;
font-weight: 400;
color: var(--text-secondary);
white-space: normal;
width: 250px;
z-index: 1000;
box-shadow: var(--shadow);
margin-bottom: 6px;
line-height: 1.4;
}
/* Mobile hamburger toggle */
.mobile-menu-btn {
display: none;
position: fixed;
top: 12px;
left: 12px;
z-index: 200;
width: 40px;
height: 40px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
align-items: center;
justify-content: center;
color: var(--text-primary);
cursor: pointer;
}
/* Responsive */
@media (max-width: 768px) {
.mobile-menu-btn { display: flex; }
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 150;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 140;
}
.sidebar-backdrop.open { display: block; }
.content { margin-left: 0; padding: 16px; padding-top: 60px; }
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
.device-grid { grid-template-columns: 1fr; }
.content-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.info-grid { grid-template-columns: 1fr 1fr; }
.remote-container { flex-direction: column; }
.remote-controls { width: 100%; flex-direction: row; flex-wrap: wrap; }
.modal { width: 95vw; max-height: 90vh; overflow-y: auto; }
.tabs { overflow-x: auto; }
.tab { white-space: nowrap; }
}

21
frontend/css/reset.css Normal file
View file

@ -0,0 +1,21 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-primary);
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
ul { list-style: none; }
button { cursor: pointer; font: inherit; border: none; background: none; color: inherit; }
input, select, textarea { font: inherit; color: inherit; }
img { max-width: 100%; display: block; }

View file

@ -0,0 +1,27 @@
:root {
--bg-primary: #111827;
--bg-secondary: #1f2937;
--bg-card: #1e293b;
--bg-card-hover: #283548;
--bg-input: #0f172a;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-dim: #1d4ed8;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--success: #22c55e;
--success-dim: #15803d;
--danger: #ef4444;
--danger-dim: #991b1b;
--warning: #f59e0b;
--warning-dim: #92400e;
--border: #334155;
--border-light: #475569;
--radius: 8px;
--radius-lg: 12px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--sidebar-width: 220px;
--transition: 0.2s ease;
}

182
frontend/index.html Normal file
View file

@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#111827">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/assets/icon-192.png">
<link rel="apple-touch-icon" href="/assets/icon-192.png">
<title>ScreenTinker</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/reset.css">
<link rel="stylesheet" href="/css/main.css">
<script src="/socket.io/socket.io.js"></script>
<!-- OAuth providers loaded on-demand by login.js when needed -->
</head>
<body>
<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>
</button>
<div class="sidebar-backdrop" id="sidebarBackdrop" onclick="document.querySelector('.sidebar').classList.remove('open');this.classList.remove('open')"></div>
<nav class="sidebar">
<div class="sidebar-header">
<div class="logo">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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="12" y1="17" x2="12" y2="21"/>
</svg>
<span>ScreenTinker</span>
</div>
</div>
<ul class="nav-links">
<li><a href="#/" class="nav-link active" data-view="dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
<span>Displays</span>
</a></li>
<li><a href="#/content" class="nav-link" data-view="content">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
</svg>
<span>Content</span>
</a></li>
<li><a href="#/layouts" class="nav-link" data-view="layouts">
<svg width="18" height="18" 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"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>
</svg>
<span>Layouts</span>
</a></li>
<li><a href="#/widgets" class="nav-link" data-view="widgets">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="8" height="8" rx="1"/><rect x="14" y="2" width="8" height="8" rx="1"/>
<rect x="2" y="14" width="8" height="8" rx="1"/><rect x="14" y="14" width="8" height="8" rx="1"/>
</svg>
<span>Widgets</span>
</a></li>
<li><a href="#/schedule" class="nav-link" data-view="schedule">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span>Schedule</span>
</a></li>
<li><a href="#/walls" class="nav-link" data-view="walls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="9" height="8" rx="1"/><rect x="13" y="3" width="9" height="8" rx="1"/>
<rect x="2" y="13" width="9" height="8" rx="1"/><rect x="13" y="13" width="9" height="8" rx="1"/>
</svg>
<span>Video Walls</span>
</a></li>
<li><a href="#/reports" class="nav-link" data-view="reports">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>
</svg>
<span>Reports</span>
</a></li>
<li><a href="#/kiosk" class="nav-link" data-view="kiosk">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8"/><path d="M12 17v4"/>
<circle cx="12" cy="10" r="1"/>
</svg>
<span>Kiosk</span>
</a></li>
<li><a href="#/designer" class="nav-link" data-view="designer">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/>
<circle cx="11" cy="11" r="2"/>
</svg>
<span>Designer</span>
</a></li>
<li><a href="#/activity" class="nav-link" data-view="activity">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
<span>Activity</span>
</a></li>
<li><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">
<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>
<span>Teams</span>
</a></li>
<li><a href="#/help" class="nav-link" data-view="help">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>Help</span>
</a></li>
<li><a href="#/settings" class="nav-link" data-view="settings">
<svg width="18" height="18" 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 0 1 0 2.83 2 2 0 0 1-2.83 0l-.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-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>Settings</span>
</a></li>
<li><a href="#/billing" class="nav-link" data-view="billing">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/>
<line x1="1" y1="10" x2="23" y2="10"/>
</svg>
<span>Subscription</span>
</a></li>
<li id="adminNavItem" style="display:none"><a href="#/admin" class="nav-link" data-view="admin">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span>Admin</span>
</a></li>
</ul>
<div class="sidebar-footer">
<div class="connection-status" id="connectionStatus">
<span class="status-dot offline"></span>
<span>Disconnected</span>
</div>
</div>
</nav>
<main class="content" id="app">
<!-- Views rendered here -->
</main>
<!-- Add Device Modal -->
<div class="modal-overlay" id="addDeviceModal" style="display:none">
<div class="modal">
<div class="modal-header">
<h3>Add Display</h3>
<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">
<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 class="modal-description">Enter the 6-digit pairing code shown on the display.</p>
<div class="form-group">
<label>Pairing Code</label>
<input type="text" id="pairingCodeInput" maxlength="6" pattern="[0-9]{6}" placeholder="000000" class="pairing-input">
</div>
<div class="form-group">
<label>Display Name (optional)</label>
<input type="text" id="deviceNameInput" placeholder="e.g., Lobby TV" class="input">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="document.getElementById('addDeviceModal').style.display='none'">Cancel</button>
<button class="btn btn-primary" id="pairDeviceBtn">Pair Display</button>
</div>
</div>
</div>
<!-- Toast container -->
<div id="toastContainer" class="toast-container"></div>
<script type="module" src="/js/app.js"></script>
</body>
</html>

101
frontend/js/api.js Normal file
View file

@ -0,0 +1,101 @@
const API_BASE = '/api';
function getAuthHeaders() {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function request(url, options = {}) {
const res = await fetch(API_BASE + url, {
headers: { 'Content-Type': 'application/json', ...getAuthHeaders(), ...options.headers },
...options,
});
if (res.status === 401) {
// Token expired or invalid - redirect to login
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.hash = '#/login';
window.location.reload();
throw new Error('Session expired');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'Request failed');
}
return res.json();
}
export const api = {
// Devices
getDevices: () => request('/devices'),
getDevice: (id) => request(`/devices/${id}`),
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
// Provisioning
pairDevice: (pairing_code, name) => request('/provision/pair', {
method: 'POST',
body: JSON.stringify({ pairing_code, name })
}),
// Content
getContent: () => request('/content'),
getContentItem: (id) => request(`/content/${id}`),
deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }),
uploadContent: async (file, onProgress) => {
const formData = new FormData();
formData.append('file', file);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/content`);
const token = localStorage.getItem('token');
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
if (onProgress) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error('Upload failed'));
}
};
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.send(formData);
});
},
addRemoteContent: (url, name, mime_type) => request('/content/remote', {
method: 'POST',
body: JSON.stringify({ url, name, mime_type })
}),
addYoutubeContent: (url, name) => request('/content/youtube', {
method: 'POST',
body: JSON.stringify({ url, name })
}),
// Assignments
getAssignments: (deviceId) => request(`/assignments/device/${deviceId}`),
addAssignment: (deviceId, data) => request(`/assignments/device/${deviceId}`, {
method: 'POST',
body: JSON.stringify(data)
}),
updateAssignment: (id, data) => request(`/assignments/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteAssignment: (id) => request(`/assignments/${id}`, { method: 'DELETE' }),
reorderAssignments: (deviceId, order) => request(`/assignments/device/${deviceId}/reorder`, {
method: 'POST',
body: JSON.stringify({ order })
}),
// Admin - Users
getUsers: () => request('/auth/users'),
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
assignPlan: (user_id, plan_id) => request('/subscription/assign', {
method: 'POST',
body: JSON.stringify({ user_id, plan_id })
}),
};

259
frontend/js/app.js Normal file
View file

@ -0,0 +1,259 @@
import { connectSocket } from './socket.js';
import * as dashboard from './views/dashboard.js';
import * as deviceDetail from './views/device-detail.js';
import * as contentLibrary from './views/content-library.js';
import * as settings from './views/settings.js';
import * as login from './views/login.js';
import * as billing from './views/billing.js';
import * as layoutEditor from './views/layout-editor.js';
import * as schedule from './views/schedule.js';
import * as widgets from './views/widgets.js';
import * as videoWall from './views/video-wall.js';
import * as reports from './views/reports.js';
import * as activity from './views/activity.js';
import * as kiosk from './views/kiosk.js';
import * as onboarding from './views/onboarding.js';
import * as help from './views/help.js';
import * as teams from './views/teams.js';
import * as admin from './views/admin.js';
import * as designer from './views/designer.js';
const app = document.getElementById('app');
const sidebar = document.querySelector('.sidebar');
let currentView = null;
function isAuthenticated() {
return !!localStorage.getItem('token');
}
function getCurrentUser() {
try {
return JSON.parse(localStorage.getItem('user'));
} catch { return null; }
}
function route() {
// Cleanup previous view
if (currentView && currentView.cleanup) currentView.cleanup();
const hash = window.location.hash || '#/';
// Auth check - redirect to login if not authenticated
if (!isAuthenticated() && hash !== '#/login') {
window.location.hash = '#/login';
return;
}
// If authenticated and on login page, redirect to dashboard or onboarding
if (isAuthenticated() && hash === '#/login') {
window.location.hash = localStorage.getItem('rd_onboarded') ? '#/' : '#/onboarding';
return;
}
// Onboarding for new users
if (hash === '#/onboarding' && isAuthenticated()) {
sidebar.style.display = 'none';
app.style.marginLeft = '0';
currentView = onboarding;
onboarding.render(app);
return;
}
// Login page - hide sidebar
if (hash === '#/login') {
sidebar.style.display = 'none';
app.style.marginLeft = '0';
currentView = login;
login.render(app);
return;
}
// Show sidebar for authenticated views
sidebar.style.display = '';
app.style.marginLeft = '';
// Update user info in sidebar
updateSidebarUser();
const navLinks = document.querySelectorAll('.nav-link');
navLinks.forEach(link => {
link.classList.remove('active');
if (hash === '#/' && link.dataset.view === 'dashboard') link.classList.add('active');
else if (hash.startsWith('#/content') && link.dataset.view === 'content') link.classList.add('active');
else if (hash.startsWith('#/settings') && link.dataset.view === 'settings') link.classList.add('active');
else if (hash.startsWith('#/billing') && link.dataset.view === 'billing') link.classList.add('active');
else if ((hash.startsWith('#/layout') || hash === '#/layouts') && link.dataset.view === 'layouts') link.classList.add('active');
else if (hash === '#/schedule' && link.dataset.view === 'schedule') link.classList.add('active');
else if (hash === '#/widgets' && link.dataset.view === 'widgets') link.classList.add('active');
else if ((hash.startsWith('#/wall') || hash === '#/walls') && link.dataset.view === 'walls') link.classList.add('active');
else if (hash === '#/reports' && link.dataset.view === 'reports') link.classList.add('active');
else if (hash === '#/activity' && link.dataset.view === 'activity') link.classList.add('active');
else if (hash === '#/designer' && link.dataset.view === 'designer') link.classList.add('active');
else if ((hash === '#/kiosk' || hash.startsWith('#/kiosk/')) && link.dataset.view === 'kiosk') link.classList.add('active');
else if (hash === '#/help' && link.dataset.view === 'help') link.classList.add('active');
else if (hash.startsWith('#/device/') && link.dataset.view === 'dashboard') link.classList.add('active');
});
// Route to view
if (hash === '#/' || hash === '#' || hash === '') {
currentView = dashboard;
dashboard.render(app);
} else if (hash.startsWith('#/device/')) {
const deviceId = hash.split('#/device/')[1].split('/')[0];
currentView = deviceDetail;
deviceDetail.render(app, deviceId);
} else if (hash === '#/content') {
currentView = contentLibrary;
contentLibrary.render(app);
} else if (hash === '#/layouts' || hash.startsWith('#/layout/')) {
currentView = layoutEditor;
layoutEditor.render(app);
} else if (hash === '#/schedule') {
currentView = schedule;
schedule.render(app);
} else if (hash === '#/widgets') {
currentView = widgets;
widgets.render(app);
} else if (hash === '#/walls' || hash.startsWith('#/wall/')) {
currentView = videoWall;
videoWall.render(app);
} else if (hash === '#/reports') {
currentView = reports;
reports.render(app);
} else if (hash === '#/kiosk' || hash.startsWith('#/kiosk/')) {
currentView = kiosk;
kiosk.render(app);
} else if (hash === '#/designer') {
currentView = designer;
designer.render(app);
} else if (hash === '#/activity') {
currentView = activity;
activity.render(app);
} else if (hash === '#/teams' || hash.startsWith('#/team/')) {
currentView = teams;
teams.render(app);
} else if (hash === '#/help' || hash.startsWith('#/help')) {
currentView = help;
help.render(app);
} else if (hash === '#/admin') {
currentView = admin;
admin.render(app);
} else if (hash === '#/settings') {
currentView = settings;
settings.render(app);
} else if (hash === '#/billing') {
currentView = billing;
billing.render(app);
} else {
currentView = dashboard;
dashboard.render(app);
}
}
function updateSidebarUser() {
const user = getCurrentUser();
if (!user) return;
// Show admin nav only for superadmins
const adminNav = document.getElementById('adminNavItem');
if (adminNav) adminNav.style.display = user.role === 'superadmin' ? '' : 'none';
let userEl = document.getElementById('sidebarUser');
if (!userEl) {
const footer = document.querySelector('.sidebar-footer');
userEl = document.createElement('div');
userEl.id = 'sidebarUser';
userEl.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid var(--border)';
footer.insertBefore(userEl, footer.firstChild);
}
userEl.innerHTML = `
${user.avatar_url ? `<img src="${user.avatar_url}" style="width:28px;height:28px;border-radius:50%">` :
`<div style="width:28px;height:28px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;color:white">${(user.name || user.email)[0].toUpperCase()}</div>`}
<div style="flex:1;min-width:0">
<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>
<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">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
`;
document.getElementById('logoutBtn')?.addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.hash = '#/login';
window.location.reload();
});
}
// Initialize
if (isAuthenticated()) {
connectSocket();
}
// Register PWA service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw-admin.js').catch(() => {});
}
// Close mobile menu on navigation
window.addEventListener('hashchange', () => {
document.querySelector('.sidebar')?.classList.remove('open');
document.getElementById('sidebarBackdrop')?.classList.remove('open');
});
// Auto-reload on frontend update (no more hard refresh needed)
let knownHash = null;
setInterval(async () => {
try {
const res = await fetch('/api/version');
const { hash } = await res.json();
if (knownHash === null) { knownHash = hash; return; }
if (hash !== knownHash) {
knownHash = hash;
const toast = document.getElementById('toastContainer');
if (toast) {
const notice = document.createElement('div');
notice.className = 'toast info';
notice.innerHTML = '<span>Dashboard updated. <a href="javascript:location.reload()" style="color:var(--accent);text-decoration:underline;font-weight:600">Reload now</a></span>';
toast.appendChild(notice);
}
}
} catch {}
}, 15000);
// Session timeout warning - check JWT expiry every minute
if (isAuthenticated()) {
setInterval(() => {
const token = localStorage.getItem('token');
if (!token) return;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresIn = (payload.exp * 1000) - Date.now();
const minutesLeft = Math.floor(expiresIn / 60000);
if (minutesLeft <= 0) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.hash = '#/login';
window.location.reload();
} else if (minutesLeft <= 30 && minutesLeft % 10 === 0) {
// Warn at 30, 20, 10 minutes
const toast = document.getElementById('toastContainer');
if (toast && !toast.querySelector('.session-warn')) {
const warn = document.createElement('div');
warn.className = 'toast info session-warn';
warn.innerHTML = `<span>Session expires in ${minutesLeft} minutes. <a href="#/login" style="color:var(--accent);text-decoration:underline" onclick="localStorage.removeItem('token');localStorage.removeItem('user')">Re-login</a></span>`;
toast.appendChild(warn);
setTimeout(() => warn.remove(), 10000);
}
}
} catch {}
}, 60000);
}
window.addEventListener('hashchange', route);
route();

View file

@ -0,0 +1,20 @@
export function showToast(message, type = 'info', duration = 4000) {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<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 === 'error' ? '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>' :
'<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>'}
</svg>
<span>${message}</span>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, duration);
}

158
frontend/js/i18n.js Normal file
View file

@ -0,0 +1,158 @@
const translations = {
en: {
// Nav
'nav.displays': 'Displays',
'nav.content': 'Content',
'nav.layouts': 'Layouts',
'nav.widgets': 'Widgets',
'nav.schedule': 'Schedule',
'nav.walls': 'Video Walls',
'nav.reports': 'Reports',
'nav.designer': 'Designer',
'nav.activity': 'Activity',
'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';
if (!translations[currentLang]) currentLang = 'en';
export function t(key) {
return translations[currentLang]?.[key] || translations.en[key] || key;
}
export function setLanguage(lang) {
currentLang = lang;
localStorage.setItem('rd_lang', lang);
}
export function getLanguage() {
return currentLang;
}
export function getAvailableLanguages() {
return [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Español' },
{ code: 'fr', name: 'Français' },
{ code: 'de', name: 'Deutsch' },
];
}

116
frontend/js/socket.js Normal file
View file

@ -0,0 +1,116 @@
let dashboardSocket = null;
const listeners = new Map();
export function connectSocket() {
const token = localStorage.getItem('token');
dashboardSocket = io('/dashboard', {
auth: { token }
});
dashboardSocket.on('connect', () => {
console.log('Dashboard connected, socket id:', dashboardSocket.id);
updateConnectionStatus(true);
emit('connected');
});
dashboardSocket.on('connect_error', (err) => {
console.error('Dashboard socket connect error:', err.message);
});
dashboardSocket.on('disconnect', (reason) => {
console.log('Dashboard disconnected:', reason);
updateConnectionStatus(false);
emit('disconnected');
});
// Device status updates
dashboardSocket.on('dashboard:device-status', (data) => {
emit('device-status', data);
});
// Screenshot ready
dashboardSocket.on('dashboard:screenshot-ready', (data) => {
emit('screenshot-ready', data);
});
// Device added
dashboardSocket.on('dashboard:device-added', (data) => {
emit('device-added', data);
});
// Device removed
dashboardSocket.on('dashboard:device-removed', (data) => {
emit('device-removed', data);
});
// Playback state
dashboardSocket.on('dashboard:playback-state', (data) => {
emit('playback-state', data);
});
// Content ack
dashboardSocket.on('dashboard:content-ack', (data) => {
emit('content-ack', data);
});
return dashboardSocket;
}
function updateConnectionStatus(connected) {
const el = document.getElementById('connectionStatus');
if (!el) return;
const dot = el.querySelector('.status-dot');
const text = el.querySelector('span:last-child');
if (connected) {
dot.className = 'status-dot online';
text.textContent = 'Connected';
} else {
dot.className = 'status-dot offline';
text.textContent = 'Disconnected';
}
}
export function on(event, callback) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event).push(callback);
}
export function off(event, callback) {
if (!listeners.has(event)) return;
const cbs = listeners.get(event);
const idx = cbs.indexOf(callback);
if (idx > -1) cbs.splice(idx, 1);
}
function emit(event, data) {
const cbs = listeners.get(event);
if (cbs) cbs.forEach(cb => cb(data));
}
export function requestScreenshot(deviceId) {
console.log('requestScreenshot:', deviceId, 'socket connected:', dashboardSocket?.connected);
if (dashboardSocket) dashboardSocket.emit('dashboard:request-screenshot', { device_id: deviceId });
}
export function startRemote(deviceId) {
console.log('startRemote:', deviceId, 'socket connected:', dashboardSocket?.connected);
if (dashboardSocket) dashboardSocket.emit('dashboard:remote-start', { device_id: deviceId });
}
export function stopRemote(deviceId) {
if (dashboardSocket) dashboardSocket.emit('dashboard:remote-stop', { device_id: deviceId });
}
export function sendTouch(deviceId, x, y, action) {
if (dashboardSocket) dashboardSocket.emit('dashboard:remote-touch', { device_id: deviceId, x, y, action });
}
export function sendKey(deviceId, keycode) {
if (dashboardSocket) dashboardSocket.emit('dashboard:remote-key', { device_id: deviceId, keycode });
}
export function sendCommand(deviceId, type, payload) {
if (dashboardSocket) dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload });
}
export function getSocket() { return dashboardSocket; }

View file

@ -0,0 +1,101 @@
import { showToast } from '../components/toast.js';
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
export async function render(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>Activity Log</h1><div class="subtitle">Audit trail of all actions</div></div>
</div>
<div id="activityList"><div class="empty-state"><h3>Loading...</h3></div></div>
<div style="text-align:center;margin-top:16px">
<button class="btn btn-secondary btn-sm" id="loadMoreBtn" style="display:none">Load More</button>
</div>
`;
let offset = 0;
const limit = 50;
async function loadActivity(append = false) {
try {
const items = await API(`/activity?limit=${limit}&offset=${offset}`);
const list = document.getElementById('activityList');
if (!append) list.innerHTML = '';
if (items.length === 0 && offset === 0) {
list.innerHTML = '<div class="empty-state"><h3>No activity yet</h3><p>Actions will appear here as you use the system.</p></div>';
return;
}
const html = items.map(item => {
const time = new Date(item.created_at * 1000);
const timeStr = time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const icon = getActionIcon(item.action);
return `
<div style="display:flex;gap:12px;padding:12px 0;border-bottom:1px solid var(--border);align-items:flex-start">
<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="font-size:13px">
<strong>${item.user_name || item.user_email || 'System'}</strong>
<span style="color:var(--text-secondary)"> ${formatAction(item.action)}</span>
</div>
${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${item.details}</div>` : ''}
</div>
<div style="font-size:11px;color:var(--text-muted);white-space:nowrap;flex-shrink:0">${timeStr}</div>
</div>
`;
}).join('');
if (append) {
list.insertAdjacentHTML('beforeend', html);
} else {
list.innerHTML = html;
}
document.getElementById('loadMoreBtn').style.display = items.length >= limit ? '' : 'none';
} catch (err) {
showToast(err.message, 'error');
}
}
document.getElementById('loadMoreBtn').onclick = () => {
offset += limit;
loadActivity(true);
};
loadActivity();
}
function getActionIcon(action) {
if (action.includes('DELETE')) return '&#128465;';
if (action.includes('POST') && action.includes('content')) return '&#128228;';
if (action.includes('POST') && action.includes('provision')) return '&#128279;';
if (action.includes('POST') && action.includes('assignment')) return '&#128203;';
if (action.includes('alert')) return '&#128276;';
if (action.includes('PUT')) return '&#9998;';
if (action.includes('POST')) return '&#10133;';
return '&#128196;';
}
function formatAction(action) {
return action
.replace('POST /api/', 'created ')
.replace('PUT /api/', 'updated ')
.replace('DELETE /api/', 'deleted ')
.replace('/provision/pair', 'paired a device')
.replace('/content/remote', 'added remote content')
.replace('/content', 'content')
.replace('/devices/:id', 'device')
.replace('/assignments/device/:deviceId', 'playlist assignment')
.replace('/assignments/:id', 'assignment')
.replace('/layouts', 'layout')
.replace('/widgets', 'widget')
.replace('/schedules', 'schedule')
.replace('/walls', 'video wall')
.replace('alert:device_offline', 'alert: device went offline');
}
export function cleanup() {}

170
frontend/js/views/admin.js Normal file
View file

@ -0,0 +1,170 @@
import { api } from '../api.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 }).then(r => r.json());
export async function render(container) {
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (user.role !== 'superadmin') {
container.innerHTML = '<div class="empty-state"><h3>Access Denied</h3><p>Platform admin access required.</p></div>';
return;
}
container.innerHTML = `
<div class="page-header">
<div><h1>Platform Admin</h1><div class="subtitle">Superadmin controls - only you can see this</div></div>
</div>
<!-- All Users -->
<div class="settings-section">
<h3>All Users</h3>
<div id="allUsersTable"><p style="color:var(--text-muted)">Loading...</p></div>
</div>
<!-- Plan Management -->
<div class="settings-section">
<h3>Subscription Plans</h3>
<div id="plansTable"><p style="color:var(--text-muted)">Loading...</p></div>
</div>
<!-- System Info -->
<div class="settings-section">
<h3>System</h3>
<div id="systemInfo"><p style="color:var(--text-muted)">Loading...</p></div>
</div>
`;
loadUsers();
loadPlans();
loadSystem();
}
async function loadUsers() {
const el = document.getElementById('allUsersTable');
try {
const [users, plans] = await Promise.all([API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json())]);
el.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid var(--border)">
<th style="padding:8px;text-align:left;color:var(--text-muted)">User</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">Auth</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">Last Login</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">Role</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">Plan</th>
<th style="padding:8px;text-align:left;color:var(--text-muted)">Actions</th>
</tr></thead>
<tbody>
${users.map(u => `
<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"><span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span></td>
<td style="padding:8px;font-size:11px;color:var(--text-muted)">${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : 'Never'}</td>
<td style="padding:8px">
<select class="input" style="width:120px;background:var(--bg-input);font-size:12px;padding:4px" data-role-user="${u.id}">
<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>
</td>
<td style="padding:8px">
<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('')}
</select>
</td>
<td style="padding:8px">
${u.role !== 'superadmin' ? `<button class="btn btn-danger btn-sm" data-delete-user="${u.id}">Remove</button>` : '<span style="color:var(--text-muted);font-size:11px">Owner</span>'}
</td>
</tr>
`).join('')}
</tbody>
</table>
<p style="color:var(--text-muted);font-size:11px;margin-top:8px">${users.length} total users</p>
`;
// Role change
el.querySelectorAll('[data-role-user]').forEach(select => {
select.onchange = async () => {
try {
await API(`/auth/users/${select.dataset.roleUser}/role`, { method: 'PUT', body: JSON.stringify({ role: select.value }) });
showToast('Role updated', 'success');
} catch (err) { showToast(err.message, 'error'); loadUsers(); }
};
});
// Plan change
el.querySelectorAll('[data-plan-user]').forEach(select => {
select.onchange = async () => {
try {
await API('/subscription/assign', { method: 'POST', body: JSON.stringify({ user_id: select.dataset.planUser, plan_id: select.value }) });
showToast('Plan updated', 'success');
} catch (err) { showToast(err.message, 'error'); loadUsers(); }
};
});
// Delete user
el.querySelectorAll('[data-delete-user]').forEach(btn => {
let confirming = false;
btn.onclick = async () => {
if (confirming) {
try { await api.deleteUser(btn.dataset.deleteUser); showToast('User removed', 'success'); loadUsers(); }
catch (err) { showToast(err.message, 'error'); }
return;
}
confirming = true; btn.textContent = 'Confirm?'; btn.style.background = 'var(--danger)'; btn.style.color = 'white';
setTimeout(() => { confirming = false; btn.textContent = 'Remove'; btn.style.background = ''; btn.style.color = ''; }, 3000);
};
});
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
}
async function loadPlans() {
const el = document.getElementById('plansTable');
try {
const plans = await fetch('/api/subscription/plans').then(r => r.json());
el.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid var(--border)">
<th style="padding:8px;text-align:left;color:var(--text-muted)">Plan</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Devices</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Storage</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Monthly</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Yearly</th>
</tr></thead>
<tbody>
${plans.map(p => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px;font-weight:500">${p.display_name}</td>
<td style="padding:8px;text-align:right">${p.max_devices === -1 ? 'Unlimited' : p.max_devices}</td>
<td style="padding:8px;text-align:right">${p.max_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 : 'Free'}</td>
<td style="padding:8px;text-align:right">${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
}
async function loadSystem() {
const el = document.getElementById('systemInfo');
try {
const version = await fetch('/api/version').then(r => r.json());
const token = localStorage.getItem('token');
el.innerHTML = `
<div class="info-grid">
<div class="info-card"><div class="info-card-label">Version</div><div class="info-card-value small">${version.version}</div></div>
<div class="info-card"><div class="info-card-label">Frontend Hash</div><div class="info-card-value small">${version.hash}</div></div>
</div>
<div style="display:flex;gap:8px;margin-top:16px">
<a href="/api/status/backup?token=${token}" class="btn btn-secondary btn-sm" style="text-decoration:none">Download DB Backup</a>
<a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">Server Status</a>
</div>
`;
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
}
export function cleanup() {}

View file

@ -0,0 +1,147 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
export async function render(container) {
container.innerHTML = `
<div class="page-header">
<div>
<h1>Subscription</h1>
<div class="subtitle">Manage your plan and billing</div>
</div>
</div>
<div id="billingContent"><div class="empty-state"><h3>Loading...</h3></div></div>
`;
try {
const [subData, plans] = await Promise.all([
fetch('/api/subscription/me', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json()),
fetch('/api/subscription/plans').then(r => r.json())
]);
const content = document.getElementById('billingContent');
content.innerHTML = `
<!-- Current Plan -->
<div class="settings-section">
<h3>Current Plan</h3>
<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>
${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">Trial - ${subData.trial.days_left} days left</span>` : ''}
</div>
${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">
<span style="font-size:20px">&#9201;</span>
<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)">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 class="info-grid" style="margin-bottom:0">
<div class="info-card">
<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 ? 'Unlimited' : subData.plan.max_devices}</span></div>
${subData.plan.max_devices > 0 ? `
<div class="progress-bar">
<div class="progress-bar-fill ${subData.usage.devices / subData.plan.max_devices > 0.8 ? 'warning' : 'success'}"
style="width:${Math.min(100, (subData.usage.devices / subData.plan.max_devices) * 100)}%"></div>
</div>` : ''}
</div>
<div class="info-card">
<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 ? 'Unlimited' : subData.plan.max_storage_mb + ' MB'}</span></div>
${subData.plan.max_storage_mb > 0 ? `
<div class="progress-bar">
<div class="progress-bar-fill ${subData.usage.storage_mb / subData.plan.max_storage_mb > 0.8 ? 'warning' : 'success'}"
style="width:${Math.min(100, (subData.usage.storage_mb / subData.plan.max_storage_mb) * 100)}%"></div>
</div>` : ''}
</div>
<div class="info-card">
<div class="info-card-label">Features</div>
<div style="font-size:13px;margin-top:4px">
${subData.plan.remote_control ? '<div style="color:var(--success)">&#10003; Remote Control</div>' : '<div style="color:var(--text-muted)">&#10007; Remote Control</div>'}
${subData.plan.remote_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; Priority Support</div>' : '<div style="color:var(--text-muted)">&#10007; Priority Support</div>'}
</div>
</div>
</div>
</div>
<!-- Plans -->
<div class="settings-section">
<h3>Available Plans</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(240px, 1fr));gap:16px">
${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">
${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:24px;font-weight:700;color:var(--accent);margin-bottom:12px">
${p.price_monthly > 0 ? `$${p.price_monthly}<span style="font-size:13px;color:var(--text-secondary);font-weight:400">/mo</span>` : 'Free'}
</div>
<div style="font-size:13px;color:var(--text-secondary);line-height:2">
<div>${p.max_devices === -1 ? 'Unlimited' : p.max_devices} devices</div>
<div>${p.max_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;'} Remote Control</div>
<div>${p.remote_url ? '&#10003;' : '&#10007;'} Remote URLs</div>
<div>${p.priority_support ? '&#10003;' : '&#10007;'} Priority Support</div>
</div>
${p.price_yearly > 0 ? `<div style="font-size:11px;color:var(--text-muted);margin-top:8px">or $${p.price_yearly}/year (save ${Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100)}%)</div>` : ''}
${!subData.self_hosted && p.price_monthly > 0 && p.id !== subData.plan.id ? `
<div style="margin-top:12px;display:flex;gap:6px">
<button class="btn btn-primary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','monthly')">Monthly</button>
${p.price_yearly > 0 ? `<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._checkout('${p.id}','yearly')">Yearly</button>` : ''}
</div>
` : ''}
${!subData.self_hosted && p.id === subData.plan.id && subData.subscription?.stripe_subscription_id ? `
<button class="btn btn-secondary btn-sm" style="width:100%;margin-top:12px" onclick="window._manageSubscription()">Manage Subscription</button>
` : ''}
</div>
`).join('')}
</div>
${subData.self_hosted ? '<p style="color:var(--text-muted);font-size:12px;margin-top:12px">Self-hosted mode: plans can be assigned by admins without billing.</p>' : ''}
</div>
`;
// Checkout handler
window._checkout = async (planId, interval) => {
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ plan_id: planId, interval })
});
const data = await res.json();
if (data.error) { showToast(data.error, 'error'); return; }
if (data.url) window.location.href = data.url;
} catch (err) {
showToast('Failed to start checkout: ' + err.message, 'error');
}
};
// Manage subscription handler (Stripe Customer Portal)
window._manageSubscription = async () => {
try {
const res = await fetch('/api/stripe/portal', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
});
const data = await res.json();
if (data.error) { showToast(data.error, 'error'); return; }
if (data.url) window.location.href = data.url;
} catch (err) {
showToast('Failed to open billing portal: ' + err.message, 'error');
}
};
// Check for payment success/cancel in URL
if (window.location.hash.includes('payment=success')) {
showToast('Payment successful! Your plan has been upgraded.', 'success');
window.location.hash = '#/billing';
}
} catch (err) {
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>Failed to load</h3><p>${err.message}</p></div>`;
}
}
export function cleanup() {}

View file

@ -0,0 +1,493 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
function formatFileSize(bytes) {
if (!bytes) return '--';
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
}
export function render(container) {
container.innerHTML = `
<div class="page-header">
<div>
<h1>Content Library <span class="help-tip" data-tip="Upload videos and images here. Select multiple files for bulk upload. Use Remote URL to stream from external sources. Click a thumbnail to preview.">?</span></h1>
<div class="subtitle">Upload and manage your media files</div>
</div>
</div>
<div style="display:flex;gap:16px;margin-bottom:24px">
<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">
<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"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p>Drop files here or click to upload</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/*">
<div class="upload-progress" id="uploadProgress" style="display:none">
<div class="upload-progress-bar">
<div class="upload-progress-fill" id="uploadProgressFill" style="width:0%"></div>
</div>
<p style="font-size:12px;color:var(--text-secondary);margin-top:6px" id="uploadProgressText">Uploading...</p>
</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="display:flex;align-items:center;gap:8px;color:var(--text-primary);font-weight:500">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
</svg>
Remote URL
</div>
<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="https://example.com/video.mp4">
<input type="text" id="remoteNameInput" class="input" placeholder="Display name (optional)">
<select id="remoteMimeType" class="input" style="background:var(--bg-input)">
<option value="video/mp4">Video (MP4)</option>
<option value="video/webm">Video (WebM)</option>
<option value="image/jpeg">Image (JPEG)</option>
<option value="image/png">Image (PNG)</option>
</select>
<button class="btn btn-primary" id="addRemoteBtn">Add Remote URL</button>
</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="display:flex;align-items:center;gap:8px;color:var(--text-primary);font-weight:500">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
</svg>
YouTube
</div>
<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="https://youtube.com/watch?v=...">
<input type="text" id="youtubeNameInput" class="input" placeholder="Display name (optional)">
<button class="btn btn-primary" id="addYoutubeBtn">Add YouTube Video</button>
</div>
</div>
</div>
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<input type="text" id="contentSearch" class="input" placeholder="Search content..." style="width:250px">
<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 class="content-grid" id="contentGrid">
<div class="empty-state" style="grid-column:1/-1"><h3>Loading...</h3></div>
</div>
`;
// File upload handling
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', () => {
handleFiles(fileInput.files);
fileInput.value = '';
});
// Remote URL handling
document.getElementById('addRemoteBtn').addEventListener('click', async () => {
const url = document.getElementById('remoteUrlInput').value.trim();
const name = document.getElementById('remoteNameInput').value.trim();
const mimeType = document.getElementById('remoteMimeType').value;
if (!url) {
showToast('Enter a URL', 'error');
return;
}
try {
await api.addRemoteContent(url, name, mimeType);
showToast('Remote content added', 'success');
document.getElementById('remoteUrlInput').value = '';
document.getElementById('remoteNameInput').value = '';
loadContent();
} catch (err) {
showToast(err.message, 'error');
}
});
// YouTube URL handling
document.getElementById('addYoutubeBtn').addEventListener('click', async () => {
const url = document.getElementById('youtubeUrlInput').value.trim();
const name = document.getElementById('youtubeNameInput').value.trim();
if (!url) {
showToast('Enter a YouTube URL', 'error');
return;
}
try {
await api.addYoutubeContent(url, name);
showToast('YouTube video added', 'success');
document.getElementById('youtubeUrlInput').value = '';
document.getElementById('youtubeNameInput').value = '';
loadContent();
} catch (err) {
showToast(err.message, 'error');
}
});
// Content search + folder filter
function filterContent() {
const q = document.getElementById('contentSearch').value.toLowerCase();
const folder = document.getElementById('folderFilter').value;
document.querySelectorAll('.content-item').forEach(item => {
const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || '';
const itemFolder = item.dataset.folder || '';
const matchSearch = !q || name.includes(q);
const matchFolder = !folder || itemFolder === folder;
item.style.display = (matchSearch && matchFolder) ? '' : 'none';
});
}
document.getElementById('contentSearch').oninput = filterContent;
document.getElementById('folderFilter').onchange = filterContent;
// New folder
document.getElementById('newFolderBtn').onclick = () => {
const name = prompt('Folder name:');
if (name) {
// Just add to the dropdown - folders are created when content is moved into them
const opt = document.createElement('option');
opt.value = name; opt.textContent = name;
document.getElementById('folderFilter').appendChild(opt);
showToast(`Folder "${name}" created. Edit content to move it here.`, 'info');
}
};
loadContent();
}
async function handleFiles(files) {
const progress = document.getElementById('uploadProgress');
const progressFill = document.getElementById('uploadProgressFill');
const progressText = document.getElementById('uploadProgressText');
for (const file of files) {
progress.style.display = 'block';
progressFill.style.width = '0%';
progressText.textContent = `Uploading ${file.name}...`;
try {
await api.uploadContent(file, (pct) => {
progressFill.style.width = pct + '%';
progressText.textContent = `Uploading ${file.name}... ${pct}%`;
});
showToast(`${file.name} uploaded successfully`, 'success');
} catch (err) {
showToast(`Failed to upload ${file.name}: ${err.message}`, 'error');
}
}
progress.style.display = 'none';
loadContent();
}
async function loadContent() {
const grid = document.getElementById('contentGrid');
if (!grid) return;
try {
const content = await api.getContent();
if (!content.length) {
grid.innerHTML = `
<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">
<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"/>
</svg>
<h3>No content yet</h3>
<p>Upload videos and images to get started.</p>
</div>
`;
return;
}
grid.innerHTML = content.map(c => `
<div class="content-item" data-content-id="${c.id}" data-folder="${c.folder || ''}">
<div class="content-item-preview">
${c.mime_type === 'video/youtube'
? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center">
<img src="${c.thumbnail_path}" alt="${c.filename}" loading="lazy" style="width:100%;height:100%;object-fit:cover">
<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">
<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" fill="white"/>
</svg>
</div>
</div>`
: c.remote_url
? `<div class="video-icon" style="flex-direction:column;gap:4px">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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"/>
</svg>
<span style="font-size:10px;color:var(--text-muted)">Remote</span>
</div>`
: c.thumbnail_path
? `<img src="/api/content/${c.id}/thumbnail" alt="${c.filename}" loading="lazy">`
: c.mime_type?.startsWith('video/')
? `<div class="video-icon">
<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"/>
</svg>
</div>`
: `<img src="/api/content/${c.id}/file" alt="${c.filename}" loading="lazy">`
}
</div>
<div class="content-item-body">
<div class="content-item-name" title="${c.filename}">${c.filename}</div>
<div class="content-item-size">
${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.file_size ? ' &middot; ' + formatFileSize(c.file_size) : ''}
${c.width && c.height ? ` &middot; ${c.width}x${c.height}` : ''}
</div>
</div>
<div class="content-item-actions">
<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">
<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"/>
</svg>
Edit
</button>
<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">
<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
</button>
</div>
</div>
`).join('');
// Populate folder dropdown
const folderSelect = document.getElementById('folderFilter');
const folders = [...new Set(content.filter(c => c.folder).map(c => c.folder))].sort();
folders.forEach(f => {
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
grid.onclick = async (e) => {
// Preview on click (not on delete button)
const previewTarget = e.target.closest('.content-item-preview');
if (previewTarget) {
const item = previewTarget.closest('.content-item');
const id = item?.dataset.contentId;
if (id) {
const c = content.find(x => x.id === id);
if (c) showPreview(c);
}
return;
}
// Edit button
const editBtn = e.target.closest('[data-edit-content]');
if (editBtn) {
const id = editBtn.dataset.editContent;
const c = content.find(x => x.id === id);
if (c) showEditModal(c, loadContent);
return;
}
const btn = e.target.closest('[data-delete-content]');
if (!btn) return;
e.stopPropagation();
const id = btn.dataset.deleteContent;
// If already confirming, do the delete
if (btn.dataset.confirming === 'true') {
try {
btn.disabled = true;
btn.textContent = 'Deleting...';
await api.deleteContent(id);
showToast('Content deleted', 'success');
loadContent();
} catch (err) {
showToast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Delete';
btn.dataset.confirming = 'false';
}
return;
}
// First click - show confirm state
btn.dataset.confirming = 'true';
btn.innerHTML = 'Confirm Delete?';
btn.style.background = 'var(--danger)';
btn.style.color = 'white';
// Reset after 3 seconds if not clicked
setTimeout(() => {
if (btn.dataset.confirming === 'true') {
btn.dataset.confirming = 'false';
btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> Delete`;
btn.style.background = '';
btn.style.color = '';
}
}, 3000);
};
} catch (err) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Failed to load content</h3><p>${err.message}</p></div>`;
}
}
function showEditModal(contentItem, onSave) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
const isRemote = !!contentItem.remote_url;
overlay.innerHTML = `
<div class="modal" style="width:500px">
<div class="modal-header">
<h3>Edit Content</h3>
<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>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Filename / Display Name</label>
<input type="text" id="editFilename" class="input" value="${contentItem.filename}">
</div>
${isRemote ? `
<div class="form-group">
<label>Remote URL</label>
<input type="text" id="editRemoteUrl" class="input" value="${contentItem.remote_url}">
</div>
` : ''}
<div class="form-group">
<label>MIME Type</label>
<select id="editMimeType" class="input" style="background:var(--bg-input)">
<option value="video/mp4" ${contentItem.mime_type === 'video/mp4' ? 'selected' : ''}>Video (MP4)</option>
<option value="video/webm" ${contentItem.mime_type === 'video/webm' ? 'selected' : ''}>Video (WebM)</option>
<option value="image/jpeg" ${contentItem.mime_type === 'image/jpeg' ? 'selected' : ''}>Image (JPEG)</option>
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>Image (PNG)</option>
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>Image (GIF)</option>
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>Image (WebP)</option>
</select>
</div>
${!isRemote ? `
<div class="form-group">
<label>Replace File</label>
<input type="file" id="editFileReplace" accept="video/*,image/*" style="font-size:13px;color:var(--text-secondary)">
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">Leave empty to keep current file</p>
</div>
` : ''}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancelEditBtn">Cancel</button>
<button class="btn btn-primary" id="saveEditBtn">Save Changes</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('#closeEditModal').onclick = () => overlay.remove();
overlay.querySelector('#cancelEditBtn').onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.querySelector('#saveEditBtn').onclick = async () => {
const filename = overlay.querySelector('#editFilename').value.trim();
const mimeType = overlay.querySelector('#editMimeType').value;
const remoteUrl = overlay.querySelector('#editRemoteUrl')?.value.trim();
const replaceFile = overlay.querySelector('#editFileReplace')?.files[0];
try {
const token = localStorage.getItem('token');
const headers = { Authorization: 'Bearer ' + token };
// Update metadata
const updateData = {};
if (filename !== contentItem.filename) updateData.filename = filename;
if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType;
if (remoteUrl !== undefined && remoteUrl !== contentItem.remote_url) updateData.remote_url = remoteUrl;
if (Object.keys(updateData).length > 0) {
await fetch('/api/content/' + contentItem.id, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
});
}
// Replace file if provided
if (replaceFile) {
const formData = new FormData();
formData.append('file', replaceFile);
await fetch('/api/content/' + contentItem.id + '/replace', {
method: 'PUT',
headers,
body: formData
});
}
overlay.remove();
showToast('Content updated', 'success');
if (onSave) onSave();
} catch (err) {
showToast(err.message || 'Update failed', 'error');
}
};
}
function showPreview(content) {
const isYoutube = content.mime_type === 'video/youtube';
const isVideo = !isYoutube && content.mime_type?.startsWith('video/');
const src = content.remote_url || `/uploads/content/${content.filepath}`;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div style="background:var(--bg-secondary);border-radius:var(--radius-lg);max-width:90vw;max-height:90vh;overflow:hidden;position:relative">
<button style="position:absolute;top:8px;right:8px;z-index:1;background:rgba(0,0,0,0.7);border:none;color:white;width:32px;height:32px;border-radius:50%;font-size:18px;cursor:pointer" id="closePreview">&times;</button>
<div style="max-width:80vw;max-height:80vh">
${isYoutube
? `<iframe src="${src}" style="width:80vw;height:45vw;max-height:80vh;display:block;border:none" allow="autoplay;encrypted-media" allowfullscreen></iframe>`
: isVideo
? `<video src="${src}" controls autoplay style="max-width:80vw;max-height:80vh;display:block"></video>`
: `<img src="${src}" style="max-width:80vw;max-height:80vh;display:block">`
}
</div>
<div style="padding:12px 16px;border-top:1px solid var(--border)">
<div style="font-weight:500">${content.filename}</div>
<div style="font-size:12px;color:var(--text-muted)">${content.mime_type} ${content.remote_url ? '(Remote URL)' : ''}</div>
</div>
</div>
`;
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.querySelector('#closePreview').onclick = () => overlay.remove();
document.body.appendChild(overlay);
}
export function cleanup() {}

View file

@ -0,0 +1,288 @@
import { api } from '../api.js';
import { on, off, requestScreenshot } from '../socket.js';
import { showToast } from '../components/toast.js';
let statusHandler = null;
let screenshotHandler = null;
let refreshInterval = null;
function formatTimeAgo(timestamp) {
if (!timestamp) return 'Never';
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return 'Just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
function formatBytes(mb) {
if (mb === null || mb === undefined) return '--';
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb} MB`;
}
function renderDeviceCard(device) {
const token = localStorage.getItem('token');
const screenshotUrl = device.screenshot_path
? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}`
: null;
return `
<div class="device-card" data-device-id="${device.id}" onclick="window.location.hash='/device/${device.id}'">
<div class="device-card-preview" id="preview-${device.id}">
${screenshotUrl
? `<img src="${screenshotUrl}" alt="Screenshot" loading="lazy">`
: `<div class="no-preview">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span>No preview available</span>
</div>`
}
<div class="device-card-status">
<span class="status-dot ${device.status}"></span>
<span>${device.status === 'provisioning' ? 'Awaiting Pairing' : device.status}</span>
</div>
${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">
${device.pairing_code}
</div>` : ''}
</div>
<div class="device-card-body">
<div class="device-card-name">${device.name}</div>
${device.owner_name || device.owner_email ? `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
${device.owner_name || device.owner_email}
</div>` : ''}
<div class="device-card-meta">
<div class="meta-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
${formatTimeAgo(device.last_heartbeat)}
</div>
${device.battery_level !== null && device.battery_level !== undefined ? `
<div class="meta-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="1" y="6" width="18" height="12" rx="2" ry="2"/><line x1="23" y1="13" x2="23" y2="11"/>
</svg>
${device.battery_level}%
</div>` : ''}
${device.wifi_rssi ? `
<div class="meta-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/>
</svg>
${device.wifi_rssi} dBm
</div>` : ''}
${device.storage_free_mb ? `
<div class="meta-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</svg>
${formatBytes(device.storage_free_mb)} free
</div>` : ''}
</div>
</div>
</div>
`;
}
export function render(container) {
container.innerHTML = `
<div class="page-header">
<div>
<h1>Displays <span class="help-tip" data-tip="Your paired display devices. Green = online, red = offline. Click a device to manage its playlist, view telemetry, or use remote control.">?</span></h1>
<div class="subtitle">Manage your remote displays</div>
</div>
<button class="btn btn-primary" id="addDeviceBtn">
<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>
Add Display
</button>
</div>
<div id="dashStats" style="display:flex;gap:12px;margin-bottom:16px"></div>
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
<input type="text" id="deviceSearch" class="input" placeholder="Search displays..." style="max-width:300px">
<select id="deviceFilter" class="input" style="width:140px;background:var(--bg-input)">
<option value="">All Status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
</select>
</div>
<div class="device-grid" id="deviceGrid">
<div class="empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<h3>Loading displays...</h3>
</div>
</div>
`;
const addBtn = container.querySelector('#addDeviceBtn');
addBtn.addEventListener('click', () => {
document.getElementById('addDeviceModal').style.display = 'flex';
document.getElementById('pairingCodeInput').value = '';
document.getElementById('deviceNameInput').value = '';
document.getElementById('pairingCodeInput').focus();
});
// Search and filter
document.getElementById('deviceSearch').oninput = () => filterDevices();
document.getElementById('deviceFilter').onchange = () => filterDevices();
function filterDevices() {
const search = document.getElementById('deviceSearch').value.toLowerCase();
const status = document.getElementById('deviceFilter').value;
document.querySelectorAll('.device-card').forEach(card => {
const name = card.querySelector('.device-card-name')?.textContent.toLowerCase() || '';
const deviceStatus = card.querySelector('.device-card-status span:last-child')?.textContent || '';
const matchSearch = !search || name.includes(search);
const matchStatus = !status || deviceStatus === status;
card.style.display = (matchSearch && matchStatus) ? '' : 'none';
});
}
// Setup pairing
const pairBtn = document.getElementById('pairDeviceBtn');
pairBtn.onclick = async () => {
const code = document.getElementById('pairingCodeInput').value.trim();
const name = document.getElementById('deviceNameInput').value.trim();
if (!code || code.length !== 6) {
showToast('Enter a valid 6-digit pairing code', 'error');
return;
}
try {
await api.pairDevice(code, name || undefined);
document.getElementById('addDeviceModal').style.display = 'none';
showToast('Display paired successfully!', 'success');
loadDevices();
} catch (err) {
showToast(err.message, 'error');
}
};
// Load devices
loadDevices();
// Real-time updates
statusHandler = (data) => {
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
if (card) {
const statusEl = card.querySelector('.device-card-status');
statusEl.innerHTML = `<span class="status-dot ${data.status}"></span><span>${data.status}</span>`;
}
};
screenshotHandler = (data) => {
const preview = document.getElementById(`preview-${data.device_id}`);
if (preview) {
const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token'));
const img = preview.querySelector('img');
if (img) {
img.src = imgSrc;
} else {
preview.innerHTML = `<img src="${imgSrc}" alt="Screenshot" loading="lazy">` +
preview.querySelector('.device-card-status').outerHTML;
}
}
};
// Device added/removed - refresh the whole list
const deviceAddedHandler = () => loadDevices();
const deviceRemovedHandler = () => loadDevices();
on('device-status', statusHandler);
on('screenshot-ready', screenshotHandler);
on('device-added', deviceAddedHandler);
on('device-removed', deviceRemovedHandler);
// Request fresh screenshots on load
setTimeout(() => {
document.querySelectorAll('.device-card').forEach(card => {
requestScreenshot(card.dataset.deviceId);
});
}, 2000);
// Refresh screenshots periodically
refreshInterval = setInterval(() => {
document.querySelectorAll('.device-card').forEach(card => {
requestScreenshot(card.dataset.deviceId);
});
}, 30000);
}
async function loadDevices() {
const grid = document.getElementById('deviceGrid');
if (!grid) return;
try {
const devices = await api.getDevices();
// Stats cards
const online = devices.filter(d => d.status === 'online').length;
const offline = devices.filter(d => d.status === 'offline').length;
const provisioning = devices.filter(d => d.status === 'provisioning').length;
const statsEl = document.getElementById('dashStats');
if (statsEl) {
statsEl.innerHTML = `
<div class="info-card" style="flex:1;min-width:120px">
<div class="info-card-label">Total Displays</div>
<div class="info-card-value">${devices.length}</div>
</div>
<div class="info-card" style="flex:1;min-width:120px">
<div class="info-card-label">Online</div>
<div class="info-card-value" style="color:var(--success)">${online}</div>
</div>
<div class="info-card" style="flex:1;min-width:120px">
<div class="info-card-label">Offline</div>
<div class="info-card-value" style="color:${offline > 0 ? 'var(--danger)' : 'var(--text-muted)'}">${offline}</div>
</div>
${provisioning > 0 ? `
<div class="info-card" style="flex:1;min-width:120px">
<div class="info-card-label">Awaiting Pairing</div>
<div class="info-card-value" style="color:var(--warning,#f59e0b)">${provisioning}</div>
</div>` : ''}
`;
}
if (devices.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column: 1/-1">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<h3>No displays yet</h3>
<p>Install the ScreenTinker app on your Apolosign TV and pair it using the button above.</p>
</div>
`;
} else {
grid.innerHTML = devices.map(renderDeviceCard).join('');
}
} catch (err) {
grid.innerHTML = `<div class="empty-state" style="grid-column: 1/-1"><h3>Failed to load displays</h3><p>${err.message}</p></div>`;
}
}
export function cleanup() {
if (statusHandler) off('device-status', statusHandler);
if (screenshotHandler) off('screenshot-ready', screenshotHandler);
off('device-added', () => {});
off('device-removed', () => {});
if (refreshInterval) clearInterval(refreshInterval);
statusHandler = null;
screenshotHandler = null;
refreshInterval = null;
}

View file

@ -0,0 +1,560 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
const BACKGROUNDS = [
{ name: 'Black', value: '#000000' },
{ name: 'Dark Blue', value: '#0f172a' },
{ name: 'Dark Gradient', value: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)' },
{ name: 'Blue Gradient', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ name: 'Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
{ name: 'Ocean', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
{ name: 'Forest', value: 'linear-gradient(135deg, #134e5e 0%, #71b280 100%)' },
{ name: 'Dark Red', value: 'linear-gradient(135deg, #200122 0%, #6f0000 100%)' },
{ name: 'White', value: '#FFFFFF' },
];
const FONTS = ['Arial', 'Helvetica', 'Georgia', 'Impact', 'Verdana', 'Trebuchet MS', 'Courier New', 'Times New Roman'];
let elements = [];
let selectedIdx = -1;
let bgValue = '#000000';
let bgImageDataUrl = null;
let dragging = null;
let dragStart = null;
export function render(container) {
elements = [];
selectedIdx = -1;
bgValue = '#000000';
bgImageDataUrl = null;
container.innerHTML = `
<div class="page-header">
<div><h1>Content Designer <span class="help-tip" data-tip="Create custom signage with live elements: clocks, weather, RSS tickers, countdowns, QR codes. Publish as a widget or export as PNG.">?</span></h1><div class="subtitle">Create dynamic signage content</div></div>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary" id="loadDesignBtn">Load Design</button>
<button class="btn btn-secondary" id="exportPngBtn">Export PNG</button>
<button class="btn btn-primary" id="publishBtn">Publish to Library</button>
</div>
</div>
<div style="display:flex;gap:20px">
<!-- Preview -->
<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="designPreview" style="position:relative;width:100%;height:100%;overflow:hidden"></div>
</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:8px">Click elements to select. Drag to reposition. Live preview updates in real-time.</p>
</div>
<!-- Sidebar -->
<div style="width:300px;display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 120px);overflow-y:auto">
<!-- Add Elements -->
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:10px">Add Element</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
<button class="btn btn-secondary btn-sm" id="addText" style="justify-content:center">&#128172; Text</button>
<button class="btn btn-secondary btn-sm" id="addHeading" style="justify-content:center">&#128220; Heading</button>
<button class="btn btn-secondary btn-sm" id="addImage" style="justify-content:center">&#128247; Image</button>
<button class="btn btn-secondary btn-sm" id="addVideo" style="justify-content:center">&#127916; Video</button>
<button class="btn btn-secondary btn-sm" id="addClock" style="justify-content:center">&#128339; Clock</button>
<button class="btn btn-secondary btn-sm" id="addDate" style="justify-content:center">&#128197; Date</button>
<button class="btn btn-secondary btn-sm" id="addWeather" style="justify-content:center">&#9925; Weather</button>
<button class="btn btn-secondary btn-sm" id="addTicker" style="justify-content:center">&#128240; Ticker</button>
<button class="btn btn-secondary btn-sm" id="addShape" style="justify-content:center">&#9632; Shape</button>
<button class="btn btn-secondary btn-sm" id="addQR" style="justify-content:center">&#9641; QR Code</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; Webpage</button>
</div>
</div>
<!-- Background -->
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:8px">Background</h4>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">
${BACKGROUNDS.map(b => `<div style="width:30px;height:30px;border-radius:4px;cursor:pointer;border:2px solid var(--border);background:${b.value}" data-bg="${b.value}" title="${b.name}"></div>`).join('')}
</div>
<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">
<button class="btn btn-secondary btn-sm" id="bgImageBtn">Image</button>
</div>
<input type="file" id="bgImageInput" style="display:none" accept="image/*">
</div>
<!-- Properties -->
<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">
<h4 style="font-size:13px">Properties</h4>
<button class="btn btn-danger btn-sm" id="deleteEl">Delete</button>
</div>
<div id="propFields"></div>
</div>
<!-- Layers -->
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:8px">Layers</h4>
<div id="layerList" style="font-size:12px"></div>
</div>
</div>
</div>
`;
// Background handlers
document.querySelectorAll('[data-bg]').forEach(el => {
el.onclick = () => { bgValue = el.dataset.bg; bgImageDataUrl = null; redraw(); };
});
document.getElementById('bgColor').oninput = (e) => { bgValue = e.target.value; bgImageDataUrl = null; redraw(); };
document.getElementById('bgImageBtn').onclick = () => document.getElementById('bgImageInput').click();
document.getElementById('bgImageInput').onchange = (e) => {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => { bgImageDataUrl = ev.target.result; redraw(); };
reader.readAsDataURL(file);
};
// Add element handlers
document.getElementById('addText').onclick = () => addElement({ type: 'text', x: 10, y: 60, text: 'Your text here', fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', bold: false, shadow: false });
document.getElementById('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 = () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*';
input.onchange = () => {
const reader = new FileReader();
reader.onload = (ev) => addElement({ type: 'image', x: 10, y: 10, width: 30, height: 30, src: ev.target.result });
reader.readAsDataURL(input.files[0]);
};
input.click();
};
document.getElementById('addVideo').onclick = () => {
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 });
};
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('addWeather').onclick = () => {
const location = prompt('City, State:', 'Milwaukee, WI');
if (location) addElement({ type: 'weather', x: 5, y: 70, fontSize: 36, color: '#FFFFFF', location, units: 'imperial' });
};
document.getElementById('addTicker').onclick = () => {
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)' });
};
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 = () => {
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' });
};
document.getElementById('addCountdown').onclick = () => {
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: 'Coming Soon' });
};
document.getElementById('addWebpage').onclick = () => {
const url = prompt('Webpage URL:');
if (url) addElement({ type: 'webpage', x: 5, y: 5, width: 40, height: 40, url });
};
document.getElementById('deleteEl').onclick = () => { if (selectedIdx >= 0) { elements.splice(selectedIdx, 1); selectedIdx = -1; redraw(); } };
// Publish as dynamic HTML content
document.getElementById('publishBtn').onclick = async () => {
try {
const html = generateHTML();
const blob = new Blob([html], { type: 'text/html' });
const file = new File([blob], `design-${Date.now()}.html`, { type: 'text/html' });
// Upload as a widget instead - create a text widget with the HTML
const res = await fetch('/api/widgets', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: JSON.stringify({ widget_type: 'text', name: `Design ${new Date().toLocaleDateString()}`, config: { html: generateInnerHTML(), css: '', background: bgValue } })
});
if (res.ok) showToast('Published as widget! Assign it to a layout zone.', 'success');
else showToast('Publish failed', 'error');
} catch (err) { showToast(err.message, 'error'); }
};
// Export PNG screenshot
document.getElementById('exportPngBtn').onclick = async () => {
try {
const preview = document.getElementById('designPreview');
// Use a canvas to capture
const canvas = document.createElement('canvas');
canvas.width = 1920; canvas.height = 1080;
const ctx = canvas.getContext('2d');
// Draw background
if (bgImageDataUrl) {
const img = new Image(); img.src = bgImageDataUrl;
await new Promise(r => { img.onload = r; });
ctx.drawImage(img, 0, 0, 1920, 1080);
} else if (bgValue.startsWith('linear')) {
const colors = bgValue.match(/#[a-f0-9]{6}/gi) || ['#000'];
const grad = ctx.createLinearGradient(0, 0, 1920, 1080);
colors.forEach((c, i) => grad.addColorStop(i / Math.max(1, colors.length - 1), c));
ctx.fillStyle = grad; ctx.fillRect(0, 0, 1920, 1080);
} else { ctx.fillStyle = bgValue; ctx.fillRect(0, 0, 1920, 1080); }
// Draw text elements
for (const el of elements) {
if (el.type === 'text' || el.type === 'clock' || el.type === 'date' || el.type === 'countdown') {
ctx.save();
ctx.font = `${el.bold ? 'bold ' : ''}${(el.fontSize / 100) * 1080}px ${el.fontFamily || 'Arial'}`;
ctx.fillStyle = el.color || '#FFF';
if (el.shadow) { ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 8; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; }
let text = el.text || el.label || '';
if (el.type === 'clock') text = new Date().toLocaleTimeString();
if (el.type === 'date') text = new Date().toLocaleDateString();
ctx.fillText(text, (el.x / 100) * 1920, (el.y / 100) * 1080 + (el.fontSize / 100) * 1080);
ctx.restore();
} else if (el.type === 'shape') {
ctx.save();
ctx.globalAlpha = el.opacity || 1;
ctx.fillStyle = el.color;
ctx.fillRect((el.x / 100) * 1920, (el.y / 100) * 1080, (el.width / 100) * 1920, (el.height / 100) * 1080);
ctx.restore();
}
}
const link = document.createElement('a');
link.download = 'signage-design.png'; link.href = canvas.toDataURL('image/png'); link.click();
} catch (err) { showToast('Export failed: ' + err.message, 'error'); }
};
// Load saved design
document.getElementById('loadDesignBtn').onclick = () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json';
input.onchange = () => {
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
elements = data.elements || [];
bgValue = data.bgValue || '#000';
bgImageDataUrl = data.bgImageDataUrl || null;
redraw();
showToast('Design loaded', 'success');
} catch { showToast('Invalid design file', 'error'); }
};
reader.readAsText(input.files[0]);
};
input.click();
};
// Mouse interaction on preview
const preview = document.getElementById('designPreview');
preview.onmousedown = (e) => {
const rect = preview.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * 100;
const py = ((e.clientY - rect.top) / rect.height) * 100;
selectedIdx = -1;
for (let i = elements.length - 1; i >= 0; i--) {
const el = elements[i];
const b = getBounds(el);
if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) {
selectedIdx = i;
dragging = el;
dragStart = { px, py, ox: el.x, oy: el.y };
break;
}
}
redraw();
};
preview.onmousemove = (e) => {
if (!dragging || !dragStart) return;
const rect = preview.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * 100;
const py = ((e.clientY - rect.top) / rect.height) * 100;
dragging.x = Math.max(0, Math.min(95, dragStart.ox + (px - dragStart.px)));
dragging.y = Math.max(0, Math.min(95, dragStart.oy + (py - dragStart.py)));
redraw();
};
preview.onmouseup = () => { dragging = null; dragStart = null; };
redraw();
}
function addElement(el) {
elements.push(el);
selectedIdx = elements.length - 1;
redraw();
}
function getBounds(el) {
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);
return { x: el.x, y: el.y, w: Math.min(w, 100), h: Math.min(h, 100) };
}
function redraw() {
const preview = document.getElementById('designPreview');
if (!preview) return;
let html = '';
// Background
if (bgImageDataUrl) {
preview.style.background = `url(${bgImageDataUrl}) center/cover`;
} else {
preview.style.background = bgValue;
}
// Elements
elements.forEach((el, i) => {
const selected = i === selectedIdx;
const border = selected ? 'outline:2px solid #3b82f6;outline-offset:2px;' : '';
const cursor = 'cursor:move;';
switch (el.type) {
case 'text':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};font-weight:${el.bold ? 'bold' : 'normal'};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}white-space:nowrap;${border}${cursor}" data-idx="${i}">${el.text}</div>`;
break;
case 'clock':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};font-weight:bold;${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="clock_${i}"></div>`;
break;
case 'date':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;font-family:${el.fontFamily};color:${el.color};${el.shadow ? 'text-shadow:2px 2px 4px rgba(0,0,0,0.5);' : ''}${border}${cursor}" data-idx="${i}" id="date_${i}"></div>`;
break;
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">`;
break;
case 'video':
html += `<video src="${el.src}" ${el.muted ? 'muted' : ''} ${el.loop ? 'loop' : ''} autoplay playsinline style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;object-fit:cover;${border}${cursor}" data-idx="${i}"></video>`;
break;
case 'shape':
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;
case 'weather':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize / 10}vw;color:${el.color};${border}${cursor}" data-idx="${i}" id="weather_${i}">&#9925; Loading...</div>`;
break;
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}">
<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>`;
break;
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}">
<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>`;
break;
case 'countdown':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color};${border}${cursor}" data-idx="${i}">
<div style="font-size:${el.fontSize / 15}vw;opacity:0.8">${el.label || ''}</div>
<div style="font-size:${el.fontSize / 10}vw;font-weight:bold" id="countdown_${i}"></div>
</div>`;
break;
case 'webpage':
html += `<iframe src="${el.url}" style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;border:none;pointer-events:none;${border}" data-idx="${i}"></iframe>`;
break;
}
});
// Add ticker animation CSS
html += `<style>@keyframes ticker { 0% { transform: translateX(100%); } 100% { transform: translateX(-100%); } }</style>`;
preview.innerHTML = html;
// Update dynamic elements
updateDynamic();
// Update properties panel
updateProps();
updateLayers();
}
function updateDynamic() {
elements.forEach((el, i) => {
if (el.type === 'clock') {
const clockEl = document.getElementById(`clock_${i}`);
if (clockEl) {
const update = () => {
const opts = { hour: '2-digit', minute: '2-digit' };
if (el.showSeconds) opts.second = '2-digit';
opts.hour12 = el.format !== '24h';
clockEl.textContent = new Date().toLocaleTimeString('en-US', opts);
};
update();
// Only set interval if element still exists
const iv = setInterval(() => { if (document.getElementById(`clock_${i}`)) update(); else clearInterval(iv); }, 1000);
}
}
if (el.type === 'date') {
const dateEl = document.getElementById(`date_${i}`);
if (dateEl) dateEl.textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
if (el.type === 'countdown') {
const cdEl = document.getElementById(`countdown_${i}`);
if (cdEl && el.targetDate) {
const update = () => {
const diff = new Date(el.targetDate) - new Date();
if (diff <= 0) { cdEl.textContent = 'NOW!'; return; }
const days = Math.floor(diff / 86400000);
const hours = Math.floor((diff % 86400000) / 3600000);
const mins = Math.floor((diff % 3600000) / 60000);
cdEl.textContent = `${days}d ${hours}h ${mins}m`;
};
update();
const iv = setInterval(() => { if (document.getElementById(`countdown_${i}`)) update(); else clearInterval(iv); }, 60000);
}
}
if (el.type === 'weather') {
const wEl = document.getElementById(`weather_${i}`);
if (wEl && el.location) {
fetch(`https://wttr.in/${encodeURIComponent(el.location)}?format=j1`).then(r => r.json()).then(d => {
const cur = d.current_condition?.[0];
if (cur) {
const temp = el.units === 'metric' ? cur.temp_C + '°C' : cur.temp_F + '°F';
wEl.textContent = `${temp} ${cur.weatherDesc?.[0]?.value || ''}`;
}
}).catch(() => { wEl.textContent = '&#9925; ' + el.location; });
}
}
if (el.type === 'ticker') {
const tEl = document.getElementById(`ticker_${i}`);
if (tEl && el.feedUrl) {
fetch(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(el.feedUrl)}`).then(r => r.json()).then(d => {
tEl.textContent = (d.items || []).map(item => item.title).join(' • ') || 'No items';
}).catch(() => { tEl.textContent = 'Feed unavailable'; });
}
}
});
}
function updateProps() {
const panel = document.getElementById('propPanel');
const fields = document.getElementById('propFields');
if (selectedIdx < 0 || !elements[selectedIdx]) { panel.style.display = 'none'; return; }
panel.style.display = 'block';
const el = elements[selectedIdx];
let html = '';
// Common position
html += `<div style="display:flex;gap:6px;margin-bottom:8px">
<div class="form-group" style="flex:1;margin:0"><label>X%</label><input type="number" class="input" value="${Math.round(el.x)}" data-prop="x" min="0" max="100"></div>
<div class="form-group" style="flex:1;margin:0"><label>Y%</label><input type="number" class="input" value="${Math.round(el.y)}" data-prop="y" min="0" max="100"></div>
</div>`;
if (el.type === 'text') {
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>Size</label><input type="range" min="8" max="120" value="${el.fontSize}" data-prop="fontSize" style="width:100%"><span style="font-size:11px;color:var(--text-muted)">${el.fontSize}px</span></div>
<div class="form-group"><label>Font</label><select class="input" style="background:var(--bg-input)" data-prop="fontFamily">${FONTS.map(f => `<option ${f === el.fontFamily ? 'selected' : ''}>${f}</option>`).join('')}</select></div>
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none;cursor:pointer"></div>
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.bold ? 'checked' : ''} data-prop="bold"> Bold</label>
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.shadow ? 'checked' : ''} data-prop="shadow"> Shadow</label>`;
} else if (el.type === 'clock') {
html += `<div class="form-group"><label>Size</label><input type="range" min="16" max="120" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
<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>Format</label><select class="input" style="background:var(--bg-input)" data-prop="format"><option ${el.format === '12h' ? 'selected' : ''} value="12h">12h</option><option ${el.format === '24h' ? 'selected' : ''} value="24h">24h</option></select></div>
<label style="font-size:12px;display:flex;gap:6px;margin:4px 0"><input type="checkbox" ${el.showSeconds ? 'checked' : ''} data-prop="showSeconds"> Show seconds</label>`;
} 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>
<div class="form-group" style="flex:1;margin:0"><label>H%</label><input type="number" class="input" value="${Math.round(el.height)}" data-prop="height"></div></div>`;
if (el.type === 'video') html += `<label style="font-size:12px;display:flex;gap:6px;margin:8px 0"><input type="checkbox" ${el.muted ? 'checked' : ''} data-prop="muted"> Muted</label>
<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') {
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"><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>Opacity</label><input type="range" min="0" max="1" step="0.1" value="${el.opacity}" data-prop="opacity" style="width:100%"></div>
<div class="form-group"><label>Shape</label><select class="input" style="background:var(--bg-input)" data-prop="shape"><option ${el.shape === 'rect' ? 'selected' : ''}>rect</option><option ${el.shape === 'circle' ? 'selected' : ''}>circle</option></select></div>`;
} else if (el.type === 'weather') {
html += `<div class="form-group"><label>Location</label><input type="text" class="input" value="${el.location}" data-prop="location"></div>
<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>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`;
} else if (el.type === 'ticker') {
html += `<div class="form-group"><label>Feed URL</label><input type="text" class="input" value="${el.feedUrl}" data-prop="feedUrl"></div>
<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>Text Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>
<div class="form-group"><label>BG Color</label><input type="text" class="input" value="${el.bgColor}" data-prop="bgColor"></div>`;
} else if (el.type === 'countdown') {
html += `<div class="form-group"><label>Target Date</label><input type="date" class="input" value="${el.targetDate}" data-prop="targetDate"></div>
<div class="form-group"><label>Label</label><input type="text" class="input" value="${el.label}" data-prop="label"></div>
<div class="form-group"><label>Size</label><input type="range" min="16" max="100" value="${el.fontSize}" data-prop="fontSize" style="width:100%"></div>
<div class="form-group"><label>Color</label><input type="color" value="${el.color}" data-prop="color" style="width:100%;height:28px;border:none"></div>`;
}
// Save design button
html += `<button class="btn btn-secondary btn-sm" style="width:100%;margin-top:8px;justify-content:center" onclick="(() => {
const a = document.createElement('a');
a.download = 'design.json';
a.href = 'data:application/json,' + encodeURIComponent(JSON.stringify({elements: ${JSON.stringify(elements)}, bgValue: '${bgValue}'}));
a.click();
})()">Save Design File</button>`;
fields.innerHTML = html;
fields.querySelectorAll('[data-prop]').forEach(input => {
const handler = () => {
const prop = input.dataset.prop;
if (input.type === 'checkbox') el[prop] = input.checked;
else if (input.type === 'number' || input.type === 'range') el[prop] = parseFloat(input.value);
else el[prop] = input.value;
redraw();
};
input.oninput = handler;
input.onchange = handler;
});
}
function updateLayers() {
const list = document.getElementById('layerList');
if (!list) return;
const typeIcons = { text: '&#128172;', clock: '&#128339;', date: '&#128197;', image: '&#128247;', video: '&#127916;', shape: '&#9632;', weather: '&#9925;', ticker: '&#128240;', qr: '&#9641;', countdown: '&#9201;', webpage: '&#127760;' };
list.innerHTML = elements.map((el, i) => `
<div style="padding:4px 8px;margin-bottom:2px;border-radius:4px;cursor:pointer;display:flex;align-items:center;gap:6px;
background:${i === selectedIdx ? 'var(--accent)' : 'var(--bg-secondary)'};
color:${i === selectedIdx ? 'white' : 'var(--text-secondary)'}" data-layer="${i}">
<span>${typeIcons[el.type] || '?'}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${el.text || el.type}</span>
</div>
`).join('') || '<p style="color:var(--text-muted)">No elements yet</p>';
list.querySelectorAll('[data-layer]').forEach(el => {
el.onclick = () => { selectedIdx = parseInt(el.dataset.layer); redraw(); };
});
}
function generateInnerHTML() {
let html = '';
elements.forEach((el, i) => {
switch (el.type) {
case 'text':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize * 10.8}px;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">${el.text}</div>`;
break;
case 'clock':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize * 10.8}px;font-family:${el.fontFamily};color:${el.color};font-weight:bold" id="c${i}"></div>
<script>setInterval(()=>{const o={hour:'2-digit',minute:'2-digit'${el.showSeconds ? ",second:'2-digit'" : ''},hour12:${el.format !== '24h'}};document.getElementById('c${i}').textContent=new Date().toLocaleTimeString('en-US',o)},1000)</script>`;
break;
case 'date':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize * 10.8}px;font-family:${el.fontFamily};color:${el.color}" id="d${i}"></div>
<script>document.getElementById('d${i}').textContent=new Date().toLocaleDateString('en-US',{weekday:'long',year:'numeric',month:'long',day:'numeric'})</script>`;
break;
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">`;
break;
case 'video':
html += `<video src="${el.src}" ${el.muted ? 'muted' : ''} ${el.loop ? 'loop' : ''} autoplay playsinline style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;object-fit:cover"></video>`;
break;
case 'shape':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;background:${el.color};opacity:${el.opacity};${el.shape === 'circle' ? 'border-radius:50%' : `border-radius:${el.radius}px`}"></div>`;
break;
case 'weather':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;font-size:${el.fontSize * 10.8}px;color:${el.color}" id="w${i}">Loading...</div>
<script>fetch('https://wttr.in/${encodeURIComponent(el.location)}?format=j1').then(r=>r.json()).then(d=>{const c=d.current_condition[0];document.getElementById('w${i}').textContent=c.temp_${el.units === 'metric' ? 'C' : 'F'}+'°${el.units === 'metric' ? 'C' : 'F'} '+c.weatherDesc[0].value}).catch(()=>{})</script>`;
break;
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">
<div style="white-space:nowrap;animation:t ${el.speed}s linear infinite;font-size:${el.fontSize * 10.8}px;color:${el.color}" id="t${i}">Loading...</div></div>
<style>@keyframes t{0%{transform:translateX(100%)}100%{transform:translateX(-100%)}}</style>
<script>fetch('https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(el.feedUrl)}').then(r=>r.json()).then(d=>{document.getElementById('t${i}').textContent=d.items.map(i=>i.title).join(' • ')}).catch(()=>{})</script>`;
break;
case 'countdown':
html += `<div style="position:absolute;left:${el.x}%;top:${el.y}%;text-align:center;color:${el.color}">
<div style="font-size:${el.fontSize * 7}px;opacity:0.8">${el.label}</div>
<div style="font-size:${el.fontSize * 10.8}px;font-weight:bold" id="cd${i}"></div></div>
<script>setInterval(()=>{const d=new Date('${el.targetDate}')-new Date();if(d<=0){document.getElementById('cd${i}').textContent='NOW!';return}document.getElementById('cd${i}').textContent=Math.floor(d/864e5)+'d '+Math.floor(d%864e5/36e5)+'h '+Math.floor(d%36e5/6e4)+'m'},6e4)</script>`;
break;
case 'webpage':
html += `<iframe src="${el.url}" style="position:absolute;left:${el.x}%;top:${el.y}%;width:${el.width}%;height:${el.height}%;border:none"></iframe>`;
break;
}
});
return html;
}
function generateHTML() {
return `<!DOCTYPE html><html><head><style>*{margin:0;padding:0;box-sizing:border-box}body{width:100vw;height:100vh;overflow:hidden;background:${bgImageDataUrl ? `url(${bgImageDataUrl}) center/cover` : bgValue}}</style></head><body>${generateInnerHTML()}</body></html>`;
}
export function cleanup() {}

File diff suppressed because it is too large Load diff

58
frontend/js/views/help.js Normal file
View file

@ -0,0 +1,58 @@
export function render(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>Help Center</h1><div class="subtitle">Quick guides and FAQ</div></div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:32px">
${[
{ 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: '&#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: '&#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: '&#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: '&#128433;', title: 'Kiosk/Touchscreen', steps: ['Go to Kiosk and create a new page', 'Add buttons with labels, icons, and actions', 'Configure the idle screen timeout', 'Preview the page in the editor', 'Assign to a device as a widget'] },
{ icon: '&#127916;', title: 'Video Walls', steps: ['Go to Video Walls and create a new wall', 'Set the grid size (e.g., 2x2)', 'Drag devices onto grid positions', 'Set bezel compensation if needed', 'Assign content to play across all displays'] },
].map(guide => `
<div class="settings-section" style="margin:0">
<h3 style="font-size:15px">${guide.icon} ${guide.title}</h3>
<ol style="padding-left:20px;list-style:decimal;margin-top:8px">
${guide.steps.map(s => `<li style="color:var(--text-secondary);font-size:13px;line-height:1.8">${s}</li>`).join('')}
</ol>
</div>
`).join('')}
</div>
<div class="settings-section">
<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: '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: 'Can I use portrait mode displays?', a: 'Yes! Set the orientation to "Portrait" in the device\'s Info tab. The content will be rotated accordingly.' },
{ q: 'What happens when a device goes offline?', a: 'Devices cache content locally, so they continue playing their playlist even without internet. You\'ll receive an email alert after 5 minutes of being offline.' },
{ q: 'Can I self-host ScreenTinker?', a: 'Yes! Deploy the server on your own infrastructure. All data stays on your network. Set SELF_HOSTED=true in the environment.' },
{ q: 'How do I update the Android app?', a: 'The app checks for updates automatically every 30 minutes. You can also force an update from the device\'s Info tab in the dashboard.' },
{ q: 'What video formats are supported?', a: 'MP4 (H.264), WebM, AVI, MKV, MOV. For best compatibility, use MP4 with H.264 encoding.' },
{ q: 'Can I white-label the dashboard?', a: 'Yes! Go to Settings > White Label to customize the brand name, colors, logo, and domain.' },
{ q: 'How do I export proof-of-play reports?', a: 'Go to Reports, set your date range and filters, then click "Export CSV".' },
{ q: 'What is a video wall?', a: 'A video wall combines multiple displays into one large screen. For example, four TVs in a 2x2 grid showing one big image/video.' },
].map(faq => `
<div style="border-bottom:1px solid var(--border);padding:12px 0">
<div style="font-weight:600;font-size:14px;margin-bottom:4px">${faq.q}</div>
<div style="color:var(--text-secondary);font-size:13px">${faq.a}</div>
</div>
`).join('')}
</div>
<div class="settings-section">
<h3>Keyboard Shortcuts</h3>
<div style="display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:13px">
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">Esc</kbd> <span style="color:var(--text-secondary)">Reset web player (on player page)</span>
<kbd style="background:var(--bg-input);padding:2px 8px;border-radius:4px;font-family:monospace">F</kbd> <span style="color:var(--text-secondary)">Toggle fullscreen (web player)</span>
</div>
</div>
`;
}
export function cleanup() {}

201
frontend/js/views/kiosk.js Normal file
View file

@ -0,0 +1,201 @@
import { showToast } from '../components/toast.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());
export async function render(container) {
const hash = window.location.hash;
if (hash.startsWith('#/kiosk/')) {
const id = hash.split('#/kiosk/')[1];
return renderEditor(container, id);
}
return renderList(container);
}
async function renderList(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>Kiosk Pages <span class="help-tip" data-tip="Create interactive touchscreen interfaces. Add buttons with icons and actions. Includes idle screen that shows after inactivity. Assign to devices as a widget.">?</span></h1><div class="subtitle">Create interactive touchscreen interfaces</div></div>
<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>
New Kiosk Page
</button>
</div>
<div class="content-grid" id="kioskGrid"></div>
`;
document.getElementById('newKioskBtn').onclick = async () => {
const name = prompt('Kiosk page name:');
if (!name) return;
const page = await API('/kiosk', { method: 'POST', body: JSON.stringify({ name }) });
window.location.hash = `#/kiosk/${page.id}`;
};
try {
const pages = await API('/kiosk');
const grid = document.getElementById('kioskGrid');
if (!pages.length) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><h3>No kiosk pages yet</h3><p>Create an interactive touchscreen interface for your displays.</p></div>';
return;
}
grid.innerHTML = pages.map(p => `
<div class="content-item" style="cursor:pointer" onclick="window.location.hash='#/kiosk/${p.id}'">
<div class="content-item-preview" style="display:flex;align-items:center;justify-content:center;background:var(--bg-primary)">
<span style="font-size:48px">&#128433;</span>
</div>
<div class="content-item-body">
<div class="content-item-name">${p.name}</div>
<div class="content-item-size">Kiosk Page</div>
</div>
<div class="content-item-actions">
<a href="/api/kiosk/${p.id}/render" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none" onclick="event.stopPropagation()">Preview</a>
<button class="btn btn-danger btn-sm" data-delete-kiosk="${p.id}" data-kiosk-name="${p.name}" onclick="event.stopPropagation()">Delete</button>
</div>
</div>
`).join('');
// Delete handler
grid.querySelectorAll('[data-delete-kiosk]').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const name = btn.dataset.kioskName;
if (!confirm(`Delete kiosk page "${name}"? This cannot be undone.`)) return;
try {
await API(`/kiosk/${btn.dataset.deleteKiosk}`, { method: 'DELETE' });
showToast('Kiosk page deleted');
renderList(container);
} catch (err) {
showToast(err.message || 'Failed to delete', 'error');
}
};
});
} catch (err) { showToast(err.message, 'error'); }
}
async function renderEditor(container, pageId) {
let page;
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 || '{}');
if (!config.buttons) config.buttons = [];
if (!config.style) config.style = {};
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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Kiosk Pages
</a>
<div class="page-header">
<h1>${page.name}</h1>
<div style="display:flex;gap:8px">
<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">Save</button>
</div>
</div>
<div style="display:flex;gap:20px">
<!-- Preview -->
<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>
</div>
<!-- Editor -->
<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">
<h4 style="font-size:13px;margin-bottom:10px">Page Settings</h4>
<div class="form-group"><label>Title</label><input type="text" id="kTitle" class="input" value="${config.title || ''}"></div>
<div class="form-group"><label>Subtitle</label><input type="text" id="kSubtitle" class="input" value="${config.subtitle || ''}"></div>
<div class="form-group"><label>Logo URL</label><input type="text" id="kLogo" class="input" value="${config.logoUrl || ''}" placeholder="https://..."></div>
<div class="form-group"><label>Footer Text</label><input type="text" id="kFooter" class="input" value="${config.footer || ''}"></div>
<div class="form-group"><label>Idle Screen Title</label><input type="text" id="kIdleTitle" class="input" value="${config.idleTitle || 'Touch to Begin'}"></div>
<div class="form-group"><label>Idle Timeout (seconds)</label><input type="number" id="kIdleTimeout" class="input" value="${config.idleTimeout || 60}"></div>
</div>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:10px">Style</h4>
<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>Text Color</label><input type="color" id="kTextColor" value="${config.style?.textColor || '#f1f5f9'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
<div class="form-group"><label>Columns</label><select id="kColumns" class="input" style="background:var(--bg-input)">
<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) === 4 ? 'selected' : ''} value="4">4</option>
</select></div>
<div class="form-group"><label>Button Color</label><input type="color" id="kBtnBg" value="${config.style?.buttonBg || '#1e293b'}" style="width:100%;height:28px;border:none;cursor:pointer"></div>
<div class="form-group"><label>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 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">
<h4 style="font-size:13px">Buttons</h4>
<button class="btn btn-secondary btn-sm" id="addBtnBtn">+ Add</button>
</div>
<div id="buttonList"></div>
</div>
</div>
</div>
`;
function renderButtons() {
const list = document.getElementById('buttonList');
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="display:flex;gap:6px;margin-bottom:6px">
<input type="text" class="input" value="${btn.icon || ''}" placeholder="Emoji" style="width:50px;text-align:center" data-btn="${i}" data-field="icon">
<input type="text" class="input" value="${btn.label || ''}" placeholder="Label" style="flex:1" data-btn="${i}" data-field="label">
</div>
<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">
<select class="input" style="background:var(--bg-input);font-size:11px;flex:1" data-btn="${i}" data-field="action">
<option value="" ${!btn.action ? 'selected' : ''}>No action</option>
<option value="url" ${btn.action === 'url' ? 'selected' : ''}>Open URL</option>
<option value="page" ${btn.action === 'page' ? 'selected' : ''}>Go to page</option>
</select>
<button class="btn-icon" style="color:var(--danger)" data-remove-btn="${i}" title="Remove">&#10005;</button>
</div>
<input type="text" class="input" value="${btn.url || btn.page || ''}" placeholder="URL or page" style="font-size:11px;margin-top:4px" data-btn="${i}" data-field="url">
</div>
`).join('') || '<p style="color:var(--text-muted);font-size:12px">No buttons yet</p>';
// Bind inputs
list.querySelectorAll('[data-btn]').forEach(input => {
input.oninput = () => {
const idx = parseInt(input.dataset.btn);
const field = input.dataset.field;
if (field === 'url' && config.buttons[idx].action === 'page') config.buttons[idx].page = input.value;
else config.buttons[idx][field] = input.tagName === 'SELECT' ? input.value : input.value;
};
});
list.querySelectorAll('[data-remove-btn]').forEach(btn => {
btn.onclick = () => { config.buttons.splice(parseInt(btn.dataset.removeBtn), 1); renderButtons(); };
});
}
document.getElementById('addBtnBtn').onclick = () => {
config.buttons.push({ label: 'New Button', sublabel: '', icon: '&#11088;', action: '', url: '' });
renderButtons();
};
document.getElementById('saveKioskBtn').onclick = async () => {
config.title = document.getElementById('kTitle').value;
config.subtitle = document.getElementById('kSubtitle').value;
config.logoUrl = document.getElementById('kLogo').value;
config.footer = document.getElementById('kFooter').value;
config.idleTitle = document.getElementById('kIdleTitle').value;
config.idleTimeout = parseInt(document.getElementById('kIdleTimeout').value) || 60;
config.style = {
...config.style,
background: document.getElementById('kBg').value,
textColor: document.getElementById('kTextColor').value,
columns: parseInt(document.getElementById('kColumns').value),
buttonBg: document.getElementById('kBtnBg').value,
buttonHover: document.getElementById('kBtnHover').value,
};
try {
await API(`/kiosk/${pageId}`, { method: 'PUT', body: JSON.stringify({ config }) });
showToast('Kiosk page saved', 'success');
document.getElementById('kioskPreview').src = `/api/kiosk/${pageId}/render?t=${Date.now()}`;
} catch (err) { showToast(err.message, 'error'); }
};
renderButtons();
}
export function cleanup() {}

View file

@ -0,0 +1,309 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.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());
export async function render(container) {
const hash = window.location.hash;
if (hash.startsWith('#/layout/')) {
const id = hash.split('#/layout/')[1];
return renderEditor(container, id);
}
return renderList(container);
}
async function renderList(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>Layouts <span class="help-tip" data-tip="Create multi-zone screen layouts. Use templates or build custom ones. Drag zones to position, resize with corner handle. Assign layouts to devices in the Playlist tab.">?</span></h1><div class="subtitle">Screen layouts and templates</div></div>
<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>
New Layout
</button>
</div>
<h3 style="margin-bottom:12px;font-size:14px;color:var(--text-secondary)">Templates</h3>
<div class="content-grid" id="templateGrid"></div>
<h3 style="margin:24px 0 12px;font-size:14px;color:var(--text-secondary)">My Layouts</h3>
<div class="content-grid" id="layoutGrid"></div>
`;
document.getElementById('newLayoutBtn').onclick = async () => {
const name = prompt('Layout name:');
if (!name) return;
const layout = await API('/layouts', { method: 'POST', body: JSON.stringify({ name, zones: [{ name: 'Main', x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 100 }] }) });
window.location.hash = `#/layout/${layout.id}`;
};
try {
const layouts = await API('/layouts');
const templates = layouts.filter(l => l.is_template);
const custom = layouts.filter(l => !l.is_template);
document.getElementById('templateGrid').innerHTML = templates.map(l => renderLayoutCard(l, true)).join('');
document.getElementById('layoutGrid').innerHTML = custom.length ? custom.map(l => renderLayoutCard(l, false)).join('') :
'<div class="empty-state" style="grid-column:1/-1"><p>No custom layouts yet</p></div>';
// Use template click
container.querySelectorAll('[data-use-template]').forEach(btn => {
btn.onclick = async () => {
const layout = await API(`/layouts/${btn.dataset.useTemplate}/duplicate`, { method: 'POST', body: '{}' });
window.location.hash = `#/layout/${layout.id}`;
};
});
// Edit layout click
container.querySelectorAll('[data-edit-layout]').forEach(btn => {
btn.onclick = () => { window.location.hash = `#/layout/${btn.dataset.editLayout}`; };
});
// Delete layout click
container.querySelectorAll('[data-delete-layout]').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const name = btn.dataset.layoutName;
if (!confirm(`Delete layout "${name}"? This cannot be undone.`)) return;
try {
await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' });
showToast('Layout deleted');
renderList(container);
} catch (err) {
showToast(err.message || 'Failed to delete layout', 'error');
}
};
});
} catch (err) {
showToast(err.message, 'error');
}
}
function renderLayoutCard(layout, isTemplate) {
return `
<div class="content-item" style="cursor:pointer">
<div class="content-item-preview" style="position:relative;background:var(--bg-primary)">
<div style="position:absolute;inset:8px;border:1px solid var(--border)">
${(layout.zones || []).map(z => `
<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;
font-size:9px;color:var(--text-muted);overflow:hidden">${z.name}</div>
`).join('')}
</div>
</div>
<div class="content-item-body">
<div class="content-item-name">${layout.name}</div>
<div class="content-item-size">${layout.zones?.length || 0} zone(s) ${isTemplate ? '• Template' : ''}</div>
</div>
<div class="content-item-actions">
${isTemplate
? `<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}">Edit</button>`
}
<button class="btn btn-danger btn-sm" data-delete-layout="${layout.id}" data-layout-name="${layout.name}" style="margin-left:4px">Delete</button>
</div>
</div>
`;
}
async function renderEditor(container, layoutId) {
let layout;
try {
layout = await API(`/layouts/${layoutId}`);
} catch { container.innerHTML = '<div class="empty-state"><h3>Layout not found</h3></div>'; return; }
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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Layouts
</a>
<div class="page-header">
<h1 id="layoutName">${layout.name}</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="addZoneBtn">Add Zone</button>
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">Save</button>
</div>
</div>
<div style="display:flex;gap:20px">
<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="canvas" style="position:relative;width:100%;padding-top:56.25%">
<!-- Zones rendered here -->
</div>
</div>
</div>
<div style="width:280px">
<h3 style="font-size:14px;margin-bottom:12px">Zones</h3>
<div id="zoneList"></div>
<div id="zoneProperties" style="margin-top:16px;display:none">
<h3 style="font-size:14px;margin-bottom:12px">Properties</h3>
<div class="form-group"><label>Name</label><input type="text" id="propName" class="input"></div>
<div class="form-group"><label>X (%)</label><input type="number" id="propX" class="input" min="0" max="100" step="0.1"></div>
<div class="form-group"><label>Y (%)</label><input type="number" id="propY" class="input" min="0" max="100" step="0.1"></div>
<div class="form-group"><label>Width (%)</label><input type="number" id="propW" class="input" min="1" max="100" step="0.1"></div>
<div class="form-group"><label>Height (%)</label><input type="number" id="propH" class="input" min="1" max="100" step="0.1"></div>
<div class="form-group"><label>Type</label>
<select id="propType" class="input" style="background:var(--bg-input)">
<option value="content">Content</option><option value="widget">Widget</option>
</select>
</div>
<button class="btn btn-danger btn-sm" id="deleteZoneBtn" style="width:100%;justify-content:center;margin-top:8px">Delete Zone</button>
</div>
</div>
</div>
`;
let zones = layout.zones || [];
let selectedZone = null;
let dragging = null;
function renderZones() {
const canvas = document.getElementById('canvas');
// Clear only zone divs
canvas.querySelectorAll('.zone-el').forEach(z => z.remove());
zones.forEach((z, i) => {
const el = document.createElement('div');
el.className = 'zone-el';
el.dataset.index = i;
el.style.cssText = `position:absolute;left:${z.x_percent}%;top:${z.y_percent}%;width:${z.width_percent}%;height:${z.height_percent}%;
background:${selectedZone === i ? 'rgba(59,130,246,0.3)' : 'rgba(59,130,246,0.1)'};
border:2px solid ${selectedZone === i ? 'var(--accent)' : 'rgba(59,130,246,0.4)'};
cursor:move;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--text-secondary);
user-select:none;z-index:${z.z_index || 0}`;
el.textContent = z.name;
// Drag to move
el.onmousedown = (e) => {
if (e.target !== el) return;
e.preventDefault();
selectedZone = i;
renderZones();
updateProperties();
const rect = canvas.getBoundingClientRect();
const startX = e.clientX;
const startY = e.clientY;
const origX = z.x_percent;
const origY = z.y_percent;
const onMove = (e2) => {
const dx = (e2.clientX - startX) / rect.width * 100;
const dy = (e2.clientY - startY) / rect.height * 100;
z.x_percent = Math.max(0, Math.min(100 - z.width_percent, Math.round((origX + dx) * 10) / 10));
z.y_percent = Math.max(0, Math.min(100 - z.height_percent, Math.round((origY + dy) * 10) / 10));
el.style.left = z.x_percent + '%';
el.style.top = z.y_percent + '%';
updateProperties();
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
// Resize handle
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.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
selectedZone = i;
const rect = canvas.getBoundingClientRect();
const onMove = (e2) => {
const newW = ((e2.clientX - rect.left) / rect.width * 100) - z.x_percent;
const newH = ((e2.clientY - rect.top) / rect.height * 100) - z.y_percent;
z.width_percent = Math.max(5, Math.min(100 - z.x_percent, Math.round(newW * 10) / 10));
z.height_percent = Math.max(5, Math.min(100 - z.y_percent, Math.round(newH * 10) / 10));
el.style.width = z.width_percent + '%';
el.style.height = z.height_percent + '%';
updateProperties();
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
el.appendChild(handle);
canvas.appendChild(el);
});
// Zone list sidebar
document.getElementById('zoneList').innerHTML = zones.map((z, i) => `
<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);
margin-bottom:4px;cursor:pointer;font-size:13px" data-zone-idx="${i}">
<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)}% ${z.zone_type}</div>
</div>
`).join('');
document.querySelectorAll('[data-zone-idx]').forEach(el => {
el.onclick = () => { selectedZone = parseInt(el.dataset.zoneIdx); renderZones(); updateProperties(); };
});
}
function updateProperties() {
const panel = document.getElementById('zoneProperties');
if (selectedZone === null || !zones[selectedZone]) { panel.style.display = 'none'; return; }
panel.style.display = 'block';
const z = zones[selectedZone];
document.getElementById('propName').value = z.name;
document.getElementById('propX').value = z.x_percent;
document.getElementById('propY').value = z.y_percent;
document.getElementById('propW').value = z.width_percent;
document.getElementById('propH').value = z.height_percent;
document.getElementById('propType').value = z.zone_type;
}
// Property input handlers
['propName', 'propX', 'propY', 'propW', 'propH', 'propType'].forEach(id => {
document.getElementById(id).oninput = () => {
if (selectedZone === null) return;
const z = zones[selectedZone];
z.name = document.getElementById('propName').value;
z.x_percent = parseFloat(document.getElementById('propX').value) || 0;
z.y_percent = parseFloat(document.getElementById('propY').value) || 0;
z.width_percent = parseFloat(document.getElementById('propW').value) || 10;
z.height_percent = parseFloat(document.getElementById('propH').value) || 10;
z.zone_type = document.getElementById('propType').value;
renderZones();
};
});
document.getElementById('addZoneBtn').onclick = () => {
zones.push({ id: null, name: `Zone ${zones.length + 1}`, x_percent: 10, y_percent: 10, width_percent: 30, height_percent: 30, z_index: 0, zone_type: 'content', fit_mode: 'cover', background_color: '#000000', sort_order: zones.length });
selectedZone = zones.length - 1;
renderZones();
updateProperties();
};
document.getElementById('deleteZoneBtn').onclick = () => {
if (selectedZone === null) return;
zones.splice(selectedZone, 1);
selectedZone = null;
renderZones();
updateProperties();
};
document.getElementById('saveLayoutBtn').onclick = async () => {
try {
// Delete existing zones and recreate
for (const z of layout.zones || []) {
await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' });
}
for (const z of zones) {
await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) });
}
showToast('Layout saved', 'success');
layout = await API(`/layouts/${layoutId}`);
zones = layout.zones;
} catch (err) {
showToast(err.message, 'error');
}
};
renderZones();
}
export function cleanup() {}

292
frontend/js/views/login.js Normal file
View file

@ -0,0 +1,292 @@
import { showToast } from '../components/toast.js';
let authConfig = null;
async function loadAuthConfig() {
if (authConfig) return authConfig;
const res = await fetch('/api/auth/config');
authConfig = await res.json();
return authConfig;
}
export async function render(container) {
const config = await loadAuthConfig();
const isSetup = config.needsSetup;
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;height:100vh;margin-left:calc(-1 * var(--sidebar-width))">
<div style="width:400px;max-width:90vw">
<div style="text-align:center;margin-bottom:32px">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" style="margin:0 auto 12px">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<h1 style="font-size:24px;font-weight:700;color:var(--accent)">ScreenTinker</h1>
<p style="color:var(--text-secondary);font-size:13px;margin-top:4px">
${isSetup ? 'Create your admin account to get started' : 'Sign in to manage your displays'}
</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 style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px">
<!-- Local Auth Form -->
<div id="localAuthForm">
<div class="form-group">
<label>Email</label>
<input type="email" id="loginEmail" class="input" placeholder="you@example.com" autocomplete="email">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="loginPassword" class="input" placeholder="••••••••" autocomplete="current-password">
</div>
${isSetup ? `
<div class="form-group">
<label>Name</label>
<input type="text" id="loginName" class="input" placeholder="Your name">
</div>
` : ''}
<button class="btn btn-primary" id="loginBtn" style="width:100%;justify-content:center;padding:10px">
${isSetup ? 'Create Admin Account' : 'Sign In'}
</button>
${!isSetup ? `
<button class="btn btn-secondary" id="showRegisterBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
Create Account
</button>
` : ''}
</div>
<!-- Register form (hidden by default) -->
<div id="registerForm" style="display:none">
<div class="form-group">
<label>Name</label>
<input type="text" id="regName" class="input" placeholder="Your name">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="regEmail" class="input" placeholder="you@example.com">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="regPassword" class="input" placeholder="At least 6 characters">
</div>
<button class="btn btn-primary" id="registerBtn" style="width:100%;justify-content:center;padding:10px">
Create Account
</button>
<button class="btn btn-secondary" id="showLoginBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
Back to Sign In
</button>
</div>
${config.googleEnabled || config.microsoftEnabled ? `
<div style="display:flex;align-items:center;gap:12px;margin:20px 0">
<hr style="flex:1;border-color:var(--border)">
<span style="color:var(--text-muted);font-size:12px">OR</span>
<hr style="flex:1;border-color:var(--border)">
</div>
` : ''}
${config.googleEnabled ? `
<div id="googleSignInContainer">
<button class="btn btn-secondary" id="googleSignInBtn" style="width:100%;justify-content:center;padding:10px;gap:8px">
<svg width="18" height="18" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<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"/>
</svg>
Sign in with Google
</button>
</div>
` : ''}
${config.microsoftEnabled ? `
<button class="btn btn-secondary" id="microsoftSignInBtn" style="width:100%;justify-content:center;padding:10px;gap:8px;margin-top:8px">
<svg width="18" height="18" viewBox="0 0 21 21">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
Sign in with Microsoft
</button>
` : ''}
</div>
<!-- Support Access (collapsible) -->
<details style="margin-top:16px">
<summary style="font-size:11px;color:var(--text-muted);cursor:pointer;text-align:center">Support Access</summary>
<div style="margin-top:8px">
<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">Authenticate with Support Token</button>
</div>
</details>
<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)">
<a href="/legal/terms.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">Terms of Service</a>
&nbsp;&middot;&nbsp;
<a href="/legal/privacy.html" target="_blank" style="color:var(--text-muted);text-decoration:underline">Privacy Policy</a>
</p>
</div>
</div>
`;
setupHandlers(config, isSetup);
}
function setupHandlers(config, isSetup) {
const showError = (msg) => {
const el = document.getElementById('loginError');
el.textContent = msg;
el.style.display = 'block';
};
// Support token login
document.getElementById('supportLoginBtn')?.addEventListener('click', async () => {
const token = document.getElementById('supportToken')?.value.trim();
if (!token) { showError('Paste a support token'); return; }
try {
const res = await fetch('/api/auth/support', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
const data = await res.json();
if (!res.ok) { showError(data.error); return; }
onAuthSuccess(data);
} catch (err) { showError('Support login failed'); }
});
// Local login/register
if (isSetup) {
document.getElementById('loginBtn')?.addEventListener('click', () => doRegister(true));
} else {
document.getElementById('loginBtn')?.addEventListener('click', doLogin);
document.getElementById('showRegisterBtn')?.addEventListener('click', () => {
document.getElementById('localAuthForm').style.display = 'none';
document.getElementById('registerForm').style.display = 'block';
});
document.getElementById('showLoginBtn')?.addEventListener('click', () => {
document.getElementById('localAuthForm').style.display = 'block';
document.getElementById('registerForm').style.display = 'none';
});
document.getElementById('registerBtn')?.addEventListener('click', () => doRegister(false));
}
// Enter key on password field
document.getElementById('loginPassword')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') isSetup ? doRegister(true) : doLogin();
});
async function doLogin() {
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value;
if (!email || !password) { showError('Email and password required'); return; }
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) { showError(data.error); return; }
onAuthSuccess(data);
} catch (err) {
showError('Login failed');
}
}
async function doRegister(isFirstUser) {
const email = document.getElementById(isFirstUser ? 'loginEmail' : 'regEmail').value.trim();
const password = document.getElementById(isFirstUser ? 'loginPassword' : 'regPassword').value;
const name = document.getElementById(isFirstUser ? 'loginName' : 'regName')?.value.trim() || '';
if (!email || !password) { showError('Email and password required'); return; }
if (password.length < 6) { showError('Password must be at least 6 characters'); return; }
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name })
});
const data = await res.json();
if (!res.ok) { showError(data.error); return; }
onAuthSuccess(data);
} catch (err) {
showError('Registration failed');
}
}
// Google Sign-In
if (config.googleEnabled) {
document.getElementById('googleSignInBtn')?.addEventListener('click', async () => {
try {
// Use Google's popup-based sign in
const client = google.accounts.oauth2.initTokenClient({
client_id: config.googleClientId,
scope: 'email profile',
callback: async (response) => {
if (response.access_token) {
// Get ID token via Google's tokeninfo
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${response.access_token}`);
const tokenData = await tokenRes.json();
// Send to our server
const res = await fetch('/api/auth/google', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: response.access_token, email: tokenData.email })
});
const data = await res.json();
if (res.ok) onAuthSuccess(data);
else showError(data.error);
}
}
});
client.requestAccessToken();
} catch (err) {
showError('Google sign-in failed');
}
});
}
// Microsoft Sign-In
if (config.microsoftEnabled) {
document.getElementById('microsoftSignInBtn')?.addEventListener('click', async () => {
try {
const msalConfig = {
auth: {
clientId: config.microsoftClientId,
authority: `https://login.microsoftonline.com/${config.microsoftTenantId}`,
redirectUri: window.location.origin
}
};
const msalInstance = new msal.PublicClientApplication(msalConfig);
await msalInstance.initialize();
const loginResponse = await msalInstance.loginPopup({ scopes: ['User.Read'] });
if (loginResponse.accessToken) {
const res = await fetch('/api/auth/microsoft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: loginResponse.accessToken })
});
const data = await res.json();
if (res.ok) onAuthSuccess(data);
else showError(data.error);
}
} catch (err) {
showError('Microsoft sign-in failed');
}
});
}
}
function onAuthSuccess(data) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
window.location.hash = '#/';
window.location.reload();
}
export function cleanup() {}

View file

@ -0,0 +1,241 @@
import { showToast } from '../components/toast.js';
const STEPS = [
{
title: 'Welcome to ScreenTinker!',
icon: '&#128075;',
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:16px">Let's get you set up in under 5 minutes.</p>
<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">
<li>Downloading the player app</li>
<li>Pairing your first display</li>
<li>Uploading and assigning content</li>
</ul>`,
action: null
},
{
title: 'Step 1: Get the Player App',
icon: '&#128229;',
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">
<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-weight:600;font-size:14px">Android APK</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">TV boxes, tablets, Fire TV</div>
</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)">
<div style="font-size:32px;margin-bottom:8px">&#127760;</div>
<div style="font-weight:600;font-size:14px">Web Player</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">Any browser, Pi, ChromeOS</div>
</a>
</div>
<p style="color:var(--text-muted);font-size:12px;margin-top:12px">Open the app on your display and enter this server URL:</p>
<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
},
{
title: 'Step 2: Pair Your Display',
icon: '&#128279;',
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">
<input type="text" id="onboardPairingCode" maxlength="6" pattern="[0-9]{6}" placeholder="000000"
style="width:240px;padding:16px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;
color:var(--text-primary);font-size:32px;font-weight:700;text-align:center;letter-spacing:8px;font-family:monospace">
</div>
<div style="text-align:center">
<input type="text" id="onboardDeviceName" placeholder="Display name (e.g., Lobby TV)"
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>
<p id="onboardPairStatus" style="color:var(--text-muted);font-size:13px;text-align:center;margin-top:12px"></p>`,
action: 'pair'
},
{
title: 'Step 3: Upload Content',
icon: '&#128228;',
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="font-size:32px;margin-bottom:8px">&#128193;</div>
<p style="color:var(--text-secondary)">Click to select a file</p>
<p style="color:var(--text-muted);font-size:12px;margin-top:4px">MP4, WebM, JPEG, PNG, GIF</p>
<input type="file" id="onboardFileInput" style="display:none" accept="video/*,image/*">
</div>
<div id="onboardUploadProgress" style="display:none;margin-top:12px">
<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>
<p id="onboardUploadText" style="font-size:12px;color:var(--text-muted);margin-top:6px">Uploading...</p>
</div>`,
action: 'upload'
},
{
title: "You're All Set!",
icon: '&#127881;',
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">
<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">
<li>Add more content in the <strong>Content Library</strong></li>
<li>Create multi-zone layouts in <strong>Layouts</strong></li>
<li>Set up a schedule in the <strong>Schedule</strong> calendar</li>
<li>Add live widgets (clock, weather, ticker) in <strong>Widgets</strong></li>
<li>Create interactive screens in <strong>Kiosk</strong></li>
<li>Design custom content in the <strong>Designer</strong></li>
</ul>
</div>`,
action: null
}
];
export function render(container) {
let currentStep = 0;
let pairedDeviceId = null;
function renderStep() {
const step = STEPS[currentStep];
const isFirst = currentStep === 0;
const isLast = currentStep === STEPS.length - 1;
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 48px)">
<div style="width:560px;max-width:95vw">
<!-- Progress -->
<div style="display:flex;gap:4px;margin-bottom:32px">
${STEPS.map((_, i) => `<div style="flex:1;height:4px;border-radius:2px;background:${i <= currentStep ? 'var(--accent)' : 'var(--border)'}"></div>`).join('')}
</div>
<div style="text-align:center;margin-bottom:24px">
<div style="font-size:48px;margin-bottom:12px">${step.icon}</div>
<h2 style="font-size:24px">${step.title}</h2>
</div>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;margin-bottom:24px">
${step.content}
</div>
<div style="display:flex;justify-content:space-between">
${isFirst ? '<div></div>' : `<button class="btn btn-secondary" id="prevBtn">Back</button>`}
<div style="display:flex;gap:8px">
${!isLast ? `<button class="btn btn-secondary" id="skipBtn" style="color:var(--text-muted)">Skip Wizard</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>
`;
// Bind buttons
document.getElementById('prevBtn')?.addEventListener('click', () => { currentStep--; renderStep(); });
document.getElementById('skipBtn')?.addEventListener('click', () => {
localStorage.setItem('rd_onboarded', 'true');
window.location.hash = '#/';
window.location.reload();
});
document.getElementById('nextBtn')?.addEventListener('click', handleNext);
// Step-specific setup
if (step.action === 'upload') {
const area = document.getElementById('onboardUploadArea');
const input = document.getElementById('onboardFileInput');
area?.addEventListener('click', () => input.click());
input?.addEventListener('change', handleUpload);
}
}
async function handleNext() {
const step = STEPS[currentStep];
if (step.action === 'pair') {
const code = document.getElementById('onboardPairingCode')?.value.trim();
const name = document.getElementById('onboardDeviceName')?.value.trim();
const status = document.getElementById('onboardPairStatus');
if (!code || code.length !== 6) {
if (status) status.textContent = 'Enter a valid 6-digit code';
return;
}
try {
if (status) status.textContent = 'Pairing...';
const token = localStorage.getItem('token');
const res = await fetch('/api/provision/pair', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ pairing_code: code, name: name || undefined })
});
const data = await res.json();
if (!res.ok) { if (status) status.textContent = data.error || 'Pairing failed'; return; }
pairedDeviceId = data.id;
showToast('Display paired!', 'success');
currentStep++;
renderStep();
} catch (err) {
if (status) status.textContent = 'Pairing failed: ' + err.message;
}
return;
}
if (currentStep === STEPS.length - 1) {
localStorage.setItem('rd_onboarded', 'true');
window.location.hash = '#/';
window.location.reload();
return;
}
currentStep++;
renderStep();
}
async function handleUpload() {
const file = document.getElementById('onboardFileInput')?.files[0];
if (!file) return;
const progress = document.getElementById('onboardUploadProgress');
const bar = document.getElementById('onboardProgressBar');
const text = document.getElementById('onboardUploadText');
if (progress) progress.style.display = 'block';
try {
const token = localStorage.getItem('token');
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/content');
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && bar) bar.style.width = Math.round((e.loaded / e.total) * 100) + '%';
};
xhr.onload = async () => {
if (xhr.status >= 200 && xhr.status < 300) {
const content = JSON.parse(xhr.responseText);
if (text) text.textContent = 'Uploaded! Assigning to display...';
// Auto-assign to paired device
if (pairedDeviceId) {
try {
await fetch(`/api/assignments/device/${pairedDeviceId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ content_id: content.id, duration_sec: 10 })
});
} catch {}
}
showToast('Content uploaded and assigned!', 'success');
currentStep++;
renderStep();
} else {
if (text) text.textContent = 'Upload failed';
}
};
xhr.onerror = () => { if (text) text.textContent = 'Upload failed'; };
xhr.send(formData);
} catch (err) {
if (text) text.textContent = 'Error: ' + err.message;
}
}
renderStep();
}
export function cleanup() {}

View file

@ -0,0 +1,188 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
export async function render(container) {
const devices = await api.getDevices();
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
container.innerHTML = `
<div class="page-header">
<div><h1>Reports <span class="help-tip" data-tip="Proof-of-play analytics. See what played, when, and on which device. Filter by date range and device. Export to CSV for ad verification.">?</span></h1><div class="subtitle">Proof-of-play analytics and device uptime</div></div>
<a class="btn btn-secondary" id="exportBtn">
<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"/>
</svg>
Export CSV
</a>
</div>
<div style="display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin:0"><label>Device</label>
<select id="reportDevice" class="input" style="width:200px;background:var(--bg-input)">
<option value="">All Devices</option>
${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
</select>
</div>
<div class="form-group" style="margin:0"><label>Start Date</label>
<input type="date" id="reportStart" class="input" value="${thirtyDaysAgo.toISOString().split('T')[0]}">
</div>
<div class="form-group" style="margin:0"><label>End Date</label>
<input type="date" id="reportEnd" class="input" value="${today.toISOString().split('T')[0]}">
</div>
<button class="btn btn-primary btn-sm" id="loadReportBtn">Load Report</button>
</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;
loadReport(); // Auto-load on page render
document.getElementById('exportBtn').onclick = () => {
const deviceId = document.getElementById('reportDevice').value;
const start = document.getElementById('reportStart').value;
const end = document.getElementById('reportEnd').value;
const token = localStorage.getItem('token');
window.open(`/api/reports/export?device_id=${deviceId}&start=${start}&end=${end}&token=${token}`, '_blank');
};
async function loadReport() {
const deviceId = document.getElementById('reportDevice').value;
const start = document.getElementById('reportStart').value;
const end = document.getElementById('reportEnd').value;
const content = document.getElementById('reportContent');
content.innerHTML = '<div class="empty-state"><h3>Loading...</h3></div>';
try {
const summary = await API(`/reports/summary?device_id=${deviceId}&start=${start}&end=${end}`);
content.innerHTML = `
<!-- Summary Cards -->
<div class="info-grid" style="margin-bottom:24px">
<div class="info-card">
<div class="info-card-label">Total Plays</div>
<div class="info-card-value">${summary.overall.total_plays.toLocaleString()}</div>
</div>
<div class="info-card">
<div class="info-card-label">Total Hours</div>
<div class="info-card-value">${summary.overall.total_hours}</div>
</div>
<div class="info-card">
<div class="info-card-label">Unique Content</div>
<div class="info-card-value">${summary.overall.unique_content}</div>
</div>
<div class="info-card">
<div class="info-card-label">Active Devices</div>
<div class="info-card-value">${summary.overall.unique_devices}</div>
</div>
<div class="info-card">
<div class="info-card-label">Avg Duration</div>
<div class="info-card-value small">${formatDuration(summary.overall.avg_duration_sec)}</div>
</div>
</div>
<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">
<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>
<!-- Plays by Hour Chart -->
<div class="settings-section" style="margin:0">
<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>
</div>
<!-- Top Content -->
<div class="settings-section" style="margin-bottom:20px">
<h3 style="font-size:14px;margin-bottom:12px">Top Content</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid var(--border)">
<th style="padding:8px;text-align:left;color:var(--text-muted)">Content</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Plays</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Total Hours</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Completion</th>
</tr></thead>
<tbody>
${summary.by_content.map(c => `
<tr style="border-bottom:1px solid var(--border)">
<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.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>
</tr>
`).join('') || '<tr><td colspan="4" style="padding:16px;text-align:center;color:var(--text-muted)">No data</td></tr>'}
</tbody>
</table>
</div>
<!-- By Device -->
<div class="settings-section">
<h3 style="font-size:14px;margin-bottom:12px">By Device</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="border-bottom:1px solid var(--border)">
<th style="padding:8px;text-align:left;color:var(--text-muted)">Device</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Plays</th>
<th style="padding:8px;text-align:right;color:var(--text-muted)">Total Hours</th>
</tr></thead>
<tbody>
${summary.by_device.map(d => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:8px">${d.device_name}</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>
</tr>
`).join('') || '<tr><td colspan="3" style="padding:16px;text-align:center;color:var(--text-muted)">No data</td></tr>'}
</tbody>
</table>
</div>
`;
// Render daily chart
renderBarChart('dailyChart', summary.by_day.map(d => ({
label: new Date(d.day).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
value: d.plays
})));
// Render hourly chart
const hourData = Array.from({ length: 24 }, (_, 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 };
});
renderBarChart('hourlyChart', hourData);
} catch (err) {
content.innerHTML = `<div class="empty-state"><h3>Error</h3><p>${err.message}</p></div>`;
}
}
}
function renderBarChart(containerId, data) {
const container = document.getElementById(containerId);
if (!container || !data.length) return;
const maxVal = Math.max(...data.map(d => d.value), 1);
container.innerHTML = data.map(d => `
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;min-width:0" title="${d.label}: ${d.value}">
<div style="font-size:9px;color:var(--text-muted);margin-bottom:2px;display:${d.value > 0 ? 'block' : 'none'}">${d.value}</div>
<div style="width:100%;max-width:20px;height:${Math.max(2, (d.value / maxVal) * 160)}px;background:var(--accent);border-radius:2px 2px 0 0;min-height:2px"></div>
<div style="font-size:8px;color:var(--text-muted);margin-top:4px;transform:rotate(-45deg);white-space:nowrap">${d.label}</div>
</div>
`).join('');
}
function formatDuration(seconds) {
if (!seconds) return '0s';
if (seconds < 60) return Math.round(seconds) + 's';
if (seconds < 3600) return Math.round(seconds / 60) + 'm';
return (seconds / 3600).toFixed(1) + 'h';
}
export function cleanup() {}

View file

@ -0,0 +1,200 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.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 HOURS = Array.from({ length: 24 }, (_, i) => i);
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export async function render(container) {
const devices = await api.getDevices();
const content = await api.getContent();
const selectedDevice = devices[0]?.id || '';
const today = new Date();
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay());
weekStart.setHours(0, 0, 0, 0);
container.innerHTML = `
<div class="page-header">
<div><h1>Schedule <span class="help-tip" data-tip="Visual weekly calendar for content scheduling. Click Add Schedule to create time slots. Set recurrence for repeating content. Higher priority overrides lower.">?</span></h1><div class="subtitle">Content scheduling calendar</div></div>
</div>
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
<select id="schedDevice" class="input" style="width:200px;background:var(--bg-input)">
${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
</select>
<button class="btn btn-secondary btn-sm" id="prevWeek">&lt; Prev</button>
<span id="weekLabel" style="color:var(--text-secondary);font-size:13px"></span>
<button class="btn btn-secondary btn-sm" id="nextWeek">Next &gt;</button>
<button class="btn btn-primary btn-sm" id="addScheduleBtn">Add Schedule</button>
</div>
<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>
<!-- Add/Edit Schedule Modal -->
<div class="modal-overlay" id="scheduleModal" style="display:none">
<div class="modal" style="width:480px">
<div class="modal-header"><h3 id="schedModalTitle">Add Schedule</h3>
<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>
</button>
</div>
<div class="modal-body">
<div class="form-group"><label>Content</label>
<select id="schedContent" class="input" style="background:var(--bg-input)">
${content.map(c => `<option value="${c.id}">${c.filename}</option>`).join('')}
</select>
</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 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>End Time</label><input type="time" id="schedEnd" class="input" value="17:00"></div>
</div>
<div class="form-group"><label>Repeat</label>
<select id="schedRepeat" class="input" style="background:var(--bg-input)">
<option value="">No repeat</option>
<option value="FREQ=DAILY">Daily</option>
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">Weekdays</option>
<option value="FREQ=WEEKLY;BYDAY=SA,SU">Weekends</option>
<option value="FREQ=WEEKLY">Weekly</option>
</select>
</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>Color</label><input type="color" id="schedColor" value="#3B82F6" style="width:60px;height:32px;border:none;cursor:pointer"></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>
`;
let currentWeekStart = new Date(weekStart);
let editingId = null;
function updateWeekLabel() {
const end = new Date(currentWeekStart);
end.setDate(end.getDate() + 6);
document.getElementById('weekLabel').textContent =
`${currentWeekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;
}
async function loadCalendar() {
const deviceId = document.getElementById('schedDevice').value;
if (!deviceId) return;
updateWeekLabel();
const events = await API(`/schedules/week?date=${currentWeekStart.toISOString()}&device_id=${deviceId}`);
const cal = document.getElementById('calendar');
let html = '<div style="background:var(--bg-secondary);border-bottom:1px solid var(--border)"></div>';
// Day headers
for (let d = 0; d < 7; d++) {
const date = new Date(currentWeekStart);
date.setDate(date.getDate() + d);
const isToday = date.toDateString() === new Date().toDateString();
html += `<div style="padding:8px;text-align:center;background:var(--bg-secondary);border-bottom:1px solid var(--border);border-left:1px solid var(--border);
${isToday ? 'color:var(--accent);font-weight:600' : 'color:var(--text-secondary)'};font-size:12px">
${DAYS[d]}<br>${date.getDate()}
</div>`;
}
// Hour rows
for (const h of HOURS) {
html += `<div style="padding:4px 8px;font-size:10px;color:var(--text-muted);border-bottom:1px solid var(--border);text-align:right">${h === 0 ? '12am' : h < 12 ? h + 'am' : h === 12 ? '12pm' : (h - 12) + 'pm'}</div>`;
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>`;
}
}
cal.innerHTML = html;
// Render events
events.forEach(ev => {
const start = new Date(ev.instance_start || ev.start_time);
const end = new Date(ev.instance_end || ev.end_time);
const dayIdx = start.getDay();
const startHour = start.getHours() + start.getMinutes() / 60;
const endHour = end.getHours() + end.getMinutes() / 60;
const duration = endHour - startHour;
const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`);
if (!cell) return;
const block = document.createElement('div');
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;
background:${ev.color || '#3B82F6'};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.85`;
block.textContent = ev.title || ev.content_name || ev.widget_name || 'Scheduled';
block.title = `${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}`;
block.onclick = () => editSchedule(ev);
cell.appendChild(block);
});
}
function editSchedule(ev) {
editingId = ev.id;
document.getElementById('schedModalTitle').textContent = 'Edit Schedule';
document.getElementById('schedContent').value = ev.content_id || '';
document.getElementById('schedTitle').value = ev.title || '';
const start = new Date(ev.start_time);
const end = new Date(ev.end_time);
document.getElementById('schedStart').value = `${String(start.getHours()).padStart(2,'0')}:${String(start.getMinutes()).padStart(2,'0')}`;
document.getElementById('schedEnd').value = `${String(end.getHours()).padStart(2,'0')}:${String(end.getMinutes()).padStart(2,'0')}`;
document.getElementById('schedRepeat').value = ev.recurrence || '';
document.getElementById('schedPriority').value = ev.priority || 0;
document.getElementById('schedColor').value = ev.color || '#3B82F6';
document.getElementById('scheduleModal').style.display = 'flex';
}
document.getElementById('addScheduleBtn').onclick = () => {
editingId = null;
document.getElementById('schedModalTitle').textContent = 'Add Schedule';
document.getElementById('schedTitle').value = '';
document.getElementById('scheduleModal').style.display = 'flex';
};
document.getElementById('saveScheduleBtn').onclick = async () => {
const deviceId = document.getElementById('schedDevice').value;
const contentId = document.getElementById('schedContent').value;
const startTime = document.getElementById('schedStart').value;
const endTime = document.getElementById('schedEnd').value;
const today = new Date().toISOString().split('T')[0];
const data = {
device_id: deviceId,
content_id: contentId,
title: document.getElementById('schedTitle').value,
start_time: `${today}T${startTime}:00`,
end_time: `${today}T${endTime}:00`,
recurrence: document.getElementById('schedRepeat').value || null,
priority: parseInt(document.getElementById('schedPriority').value) || 0,
color: document.getElementById('schedColor').value,
};
try {
if (editingId) {
await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) });
} else {
await API('/schedules', { method: 'POST', body: JSON.stringify(data) });
}
document.getElementById('scheduleModal').style.display = 'none';
showToast('Schedule saved', 'success');
loadCalendar();
} catch (err) {
showToast(err.message, 'error');
}
};
document.getElementById('schedDevice').onchange = loadCalendar;
document.getElementById('prevWeek').onclick = () => { currentWeekStart.setDate(currentWeekStart.getDate() - 7); loadCalendar(); };
document.getElementById('nextWeek').onclick = () => { currentWeekStart.setDate(currentWeekStart.getDate() + 7); loadCalendar(); };
loadCalendar();
}
export function cleanup() {}

View file

@ -0,0 +1,457 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { getLanguage, setLanguage, getAvailableLanguages } from '../i18n.js';
export async function render(container) {
const serverUrl = `${window.location.protocol}//${window.location.host}`;
const user = JSON.parse(localStorage.getItem('user') || '{}');
const isSuperAdmin = user.role === 'superadmin';
const isAdmin = user.role === 'admin' || isSuperAdmin;
container.innerHTML = `
<div class="page-header">
<div>
<h1>Settings</h1>
<div class="subtitle">Server configuration and setup information</div>
</div>
</div>
${isAdmin ? `
<div class="settings-section">
<h3>License</h3>
<div id="licenseSection"><p style="color:var(--text-muted);font-size:13px">MIT License - all features included.</p></div>
</div>
${isSuperAdmin ? '<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">Platform admin tools are in the <a href="#/admin" style="color:var(--accent)">Admin</a> page.</p>' : ''}
<div class="settings-section">
<h3>User Management</h3>
<div id="userManagement"><p style="color:var(--text-muted)">Loading users...</p></div>
</div>
<div class="settings-section" id="whiteLabelSection">
<h3>White Label / Branding</h3>
<div id="whiteLabelForm">
<p style="color:var(--text-muted);font-size:12px;margin-bottom:16px">Customize the look of your dashboard and player for your clients.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group"><label>Brand Name</label><input type="text" id="wlBrandName" class="input" placeholder="ScreenTinker"></div>
<div class="form-group"><label>Logo URL</label><input type="text" id="wlLogoUrl" class="input" placeholder="https://..."></div>
<div class="form-group"><label>Primary Color</label><input type="color" id="wlPrimaryColor" value="#3B82F6" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
<div class="form-group"><label>Background Color</label><input type="color" id="wlBgColor" value="#111827" style="width:100%;height:36px;border:none;cursor:pointer;border-radius:var(--radius)"></div>
<div class="form-group"><label>Custom Domain</label><input type="text" id="wlDomain" class="input" placeholder="signage.yourcompany.com"></div>
<div class="form-group"><label>Favicon URL</label><input type="text" id="wlFavicon" class="input" placeholder="https://..."></div>
</div>
<div class="form-group"><label>Custom CSS (optional)</label><textarea id="wlCustomCss" class="input" rows="3" style="font-family:monospace;font-size:12px" placeholder=":root { --accent: #ff6600; }"></textarea></div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px"><input type="checkbox" id="wlHideBranding"> Hide "ScreenTinker" branding</label></div>
<button class="btn btn-primary btn-sm" id="saveWhiteLabelBtn">Save Branding</button>
<button class="btn btn-secondary btn-sm" id="previewWhiteLabelBtn" style="margin-left:8px">Preview</button>
</div>
</div>
` : ''}
<div class="settings-section">
<h3>Server Information</h3>
<div class="info-grid">
<div class="info-card">
<div class="info-card-label">Server URL</div>
<div class="info-card-value small">${serverUrl}</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">Use this URL when setting up the Android app</p>
</div>
<div class="info-card">
<div class="info-card-label">API Endpoint</div>
<div class="info-card-value small">${serverUrl}/api</div>
</div>
</div>
</div>
<div class="settings-section">
<h3>Player Downloads</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px">
<div style="font-size:24px;margin-bottom:8px">&#129302;</div>
<div style="font-weight:600;margin-bottom:4px">Android APK</div>
<div style="font-size:11px;color:var(--text-muted);margin-bottom:12px">Apolosign, Fire TV, any Android device</div>
<a href="/download/apk" class="btn btn-primary btn-sm" style="text-decoration:none;width:100%;justify-content:center">Download APK</a>
</div>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px">
<div style="font-size:24px;margin-bottom:8px">&#127760;</div>
<div style="font-weight:600;margin-bottom:4px">Web Player</div>
<div style="font-size:11px;color:var(--text-muted);margin-bottom:12px">Any browser, ChromeOS, Smart TVs</div>
<a href="/player" target="_blank" class="btn btn-primary btn-sm" style="text-decoration:none;width:100%;justify-content:center">Open Player</a>
</div>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px">
<div style="font-size:24px;margin-bottom:8px">&#129359;</div>
<div style="font-weight:600;margin-bottom:4px">Raspberry Pi</div>
<div style="font-size:11px;color:var(--text-muted);margin-bottom:12px">Auto-start kiosk mode on Pi OS</div>
<a href="/scripts/raspberry-pi-setup.sh" class="btn btn-secondary btn-sm" style="text-decoration:none;width:100%;justify-content:center">Download Script</a>
<div style="font-size:10px;color:var(--text-muted);margin-top:6px"><code>curl -sSL ${serverUrl}/scripts/raspberry-pi-setup.sh | bash</code></div>
</div>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px">
<div style="font-size:24px;margin-bottom:8px">&#128187;</div>
<div style="font-weight:600;margin-bottom:4px">Windows</div>
<div style="font-size:11px;color:var(--text-muted);margin-bottom:12px">Chrome kiosk mode on Windows</div>
<a href="/scripts/windows-setup.bat" class="btn btn-secondary btn-sm" style="text-decoration:none;width:100%;justify-content:center">Download Script</a>
</div>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px">
<div style="font-size:24px;margin-bottom:8px">&#128250;</div>
<div style="font-weight:600;margin-bottom:4px">LG webOS / Samsung Tizen</div>
<div style="font-size:11px;color:var(--text-muted);margin-bottom:12px">Use the TV's built-in browser</div>
<div style="font-size:11px;color:var(--text-secondary)">Navigate to:<br><code>${serverUrl}/player</code></div>
</div>
</div>
</div>
<div class="settings-section">
<h3>Setup Guide</h3>
<div style="color:var(--text-secondary);font-size:13px;line-height:1.8">
<ol style="padding-left:20px;list-style:decimal">
<li>Install the <strong>ScreenTinker</strong> APK on your Apolosign portable TV via sideloading</li>
<li>Open the app and enter this server URL: <code style="background:var(--bg-input);padding:2px 6px;border-radius:4px">${serverUrl}</code></li>
<li>The app will display a <strong>6-digit pairing code</strong></li>
<li>Click <strong>"Add Display"</strong> on the dashboard and enter the pairing code</li>
<li>Upload content in the <strong>Content Library</strong></li>
<li>Assign content to the display's <strong>Playlist</strong></li>
</ol>
</div>
</div>
${isAdmin ? `
` : ''}
<div class="settings-section">
<h3>Your Data</h3>
<p style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">Export or import your devices, content, layouts, schedules, and all settings. Use this to migrate between cloud and self-hosted instances.</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="exportDataBtn">
<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"/>
</svg>
Export My Data
</button>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);cursor:pointer">
<input type="checkbox" id="exportIncludeFiles"> Include media files (ZIP)
</label>
<button class="btn btn-secondary btn-sm" id="importDataBtn">
<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="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Import Data
</button>
<input type="file" id="importFileInput" accept=".json,.zip" style="display:none">
</div>
<div id="importStatus" style="display:none;margin-top:12px;padding:12px;border-radius:var(--radius);font-size:13px"></div>
</div>
<div class="settings-section">
<h3>Language</h3>
<select id="langSelect" class="input" style="width:200px;background:var(--bg-input)">
${getAvailableLanguages().map(l => `<option value="${l.code}" ${l.code === getLanguage() ? 'selected' : ''}>${l.name}</option>`).join('')}
</select>
</div>
<div class="settings-section">
<h3>About</h3>
<div style="color:var(--text-secondary);font-size:13px">
<p><strong>ScreenTinker</strong> v1.4.1</p>
<p style="margin-top:4px">Digital signage management system.</p>
<p style="margin-top:12px">
<a href="/legal/terms.html" target="_blank" style="color:var(--accent);font-size:12px">Terms of Service</a>
&nbsp;&middot;&nbsp;
<a href="/legal/privacy.html" target="_blank" style="color:var(--accent);font-size:12px">Privacy Policy</a>
&nbsp;&middot;&nbsp;
<a href="/legal/third-party.html" target="_blank" style="color:var(--accent);font-size:12px">Third-Party Licenses</a>
</p>
</div>
</div>
`;
if (isAdmin) {
loadUsers();
loadWhiteLabel();
// Support token generator
document.getElementById('generateSupportBtn')?.addEventListener('click', async () => {
const org = document.getElementById('supportOrg').value.trim() || 'Customer';
const hours = parseInt(document.getElementById('supportHours').value) || 4;
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/auth/support/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ org, hours, reason: 'Support session' })
});
const data = await res.json();
if (res.ok) {
document.getElementById('supportTokenOutput').value = data.token;
document.getElementById('supportTokenResult').style.display = 'block';
showToast(`Support token generated (valid ${hours}h)`, 'success');
} else showToast(data.error, 'error');
} catch (err) { showToast(err.message, 'error'); }
});
}
// Export data handler
document.getElementById('exportDataBtn')?.addEventListener('click', () => {
const includeFiles = document.getElementById('exportIncludeFiles')?.checked;
const token = localStorage.getItem('token');
const url = `/api/status/export?token=${token}${includeFiles ? '&include_files=true' : ''}`;
window.location.href = url;
});
// Import data handler
document.getElementById('importDataBtn')?.addEventListener('click', () => {
document.getElementById('importFileInput').click();
});
document.getElementById('importFileInput')?.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const isZip = file.name.endsWith('.zip') || file.type === 'application/zip';
const statusEl = document.getElementById('importStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-secondary)';
statusEl.style.border = '1px solid var(--border)';
statusEl.style.color = 'var(--text-secondary)';
statusEl.textContent = 'Reading file...';
try {
let data;
if (isZip) {
// For ZIP, show basic info and skip preview parsing
data = { format: 'screentinker-export-v1', _isZip: true };
statusEl.innerHTML = `ZIP export detected: <strong>${file.name}</strong> (${(file.size / 1048576).toFixed(1)} MB)<br>Contains data + media files.<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`;
} else {
const text = await file.text();
data = JSON.parse(text);
if (!data.format || !data.format.startsWith('screentinker-export')) {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Invalid file. Must be a ScreenTinker export JSON or ZIP.';
return;
}
const summary = [
data.devices?.length ? `${data.devices.length} devices` : null,
data.content?.length ? `${data.content.length} content items` : null,
data.widgets?.length ? `${data.widgets.length} widgets` : null,
data.layouts?.length ? `${data.layouts.length} layouts` : null,
data.schedules?.length ? `${data.schedules.length} schedules` : null,
data.video_walls?.length ? `${data.video_walls.length} video walls` : null,
data.kiosk_pages?.length ? `${data.kiosk_pages.length} kiosk pages` : null,
].filter(Boolean).join(', ');
statusEl.innerHTML = `Found: ${summary || 'empty export'}.<br>From: ${data.user?.email || 'unknown'} (exported ${data.exported_at?.split('T')[0] || 'unknown'})<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`;
}
document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; };
document.getElementById('confirmImportBtn').onclick = async () => {
statusEl.innerHTML = isZip ? 'Uploading and importing... This may take a moment for large files.' : 'Importing...';
try {
const token = localStorage.getItem('token');
let res;
if (isZip) {
const formData = new FormData();
formData.append('file', file);
res = await fetch('/api/status/import', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
} else {
res = await fetch('/api/status/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(data),
});
}
const result = await res.json();
if (res.ok) {
const imported = Object.entries(result.stats).filter(([k,v]) => v > 0 && k !== 'files_restored').map(([k,v]) => `${v} ${k}`).join(', ');
statusEl.style.color = 'var(--success)';
let html = `Import complete: ${imported}.`;
if (result.device_pairings?.length) {
html += `<br><br><strong>Device Pairing Codes:</strong><br><table style="margin-top:8px;font-size:12px;border-collapse:collapse">` +
result.device_pairings.map(d => `<tr><td style="padding:4px 12px 4px 0">${d.name}</td><td style="font-family:monospace;font-weight:700;font-size:14px;letter-spacing:2px">${d.pairing_code}</td></tr>`).join('') +
`</table><br>Enter these codes on each device to re-link them. All assignments and schedules will be preserved.`;
}
html += `<br><br>${(result.notes || []).map(n => '&bull; ' + n).join('<br>')}`;
statusEl.innerHTML = html;
showToast('Data imported successfully', 'success');
} else {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = result.error || 'Import failed';
}
} catch (err) {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Import failed: ' + err.message;
}
e.target.value = '';
};
} catch (err) {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = 'Failed to read file: ' + err.message;
}
});
document.getElementById('langSelect')?.addEventListener('change', (e) => {
setLanguage(e.target.value);
showToast('Language changed. Refresh for full effect.', 'info');
});
}
async function loadWhiteLabel() {
const token = localStorage.getItem('token');
const headers = { Authorization: `Bearer ${token}` };
// Only show white-label for enterprise/superadmin
const user = JSON.parse(localStorage.getItem('user') || '{}');
const section = document.getElementById('whiteLabelSection');
if (section && user.plan_id !== 'enterprise' && user.role !== 'superadmin') {
section.innerHTML = `
<h3>White Label / Branding</h3>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center">
<p style="color:var(--text-secondary);font-size:14px;margin-bottom:8px">Custom branding is available on the Enterprise plan</p>
<a href="#/billing" class="btn btn-secondary btn-sm" style="text-decoration:none">View Plans</a>
</div>
`;
return;
}
try {
const res = await fetch('/api/white-label', { headers });
const wl = await res.json();
if (wl.brand_name) document.getElementById('wlBrandName').value = wl.brand_name;
if (wl.logo_url) document.getElementById('wlLogoUrl').value = wl.logo_url;
if (wl.primary_color) document.getElementById('wlPrimaryColor').value = wl.primary_color;
if (wl.bg_color) document.getElementById('wlBgColor').value = wl.bg_color;
if (wl.custom_domain) document.getElementById('wlDomain').value = wl.custom_domain;
if (wl.favicon_url) document.getElementById('wlFavicon').value = wl.favicon_url;
if (wl.custom_css) document.getElementById('wlCustomCss').value = wl.custom_css;
if (wl.hide_branding) document.getElementById('wlHideBranding').checked = true;
} catch {}
document.getElementById('saveWhiteLabelBtn')?.addEventListener('click', async () => {
try {
await fetch('/api/white-label', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
brand_name: document.getElementById('wlBrandName').value,
logo_url: document.getElementById('wlLogoUrl').value,
primary_color: document.getElementById('wlPrimaryColor').value,
bg_color: document.getElementById('wlBgColor').value,
custom_domain: document.getElementById('wlDomain').value,
favicon_url: document.getElementById('wlFavicon').value,
custom_css: document.getElementById('wlCustomCss').value,
hide_branding: document.getElementById('wlHideBranding').checked ? 1 : 0,
})
});
showToast('Branding saved', 'success');
} catch (err) {
showToast(err.message, 'error');
}
});
document.getElementById('previewWhiteLabelBtn')?.addEventListener('click', () => {
const primary = document.getElementById('wlPrimaryColor').value;
const bg = document.getElementById('wlBgColor').value;
document.documentElement.style.setProperty('--accent', primary);
document.documentElement.style.setProperty('--bg-primary', bg);
showToast('Preview applied (refresh to reset)', 'info');
});
}
async function loadUsers() {
const el = document.getElementById('userManagement');
if (!el) return;
try {
const [users, plans] = await Promise.all([
api.getUsers(),
fetch('/api/subscription/plans').then(r => r.json())
]);
const currentUser = JSON.parse(localStorage.getItem('user') || '{}');
el.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="border-bottom:1px solid var(--border);text-align:left">
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">User</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Auth</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Role</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Plan</th>
<th style="padding:8px 12px;color:var(--text-muted);font-weight:500">Actions</th>
</tr>
</thead>
<tbody>
${users.map(u => `
<tr style="border-bottom:1px solid var(--border)" data-user-id="${u.id}">
<td style="padding:10px 12px">
<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:10px 12px">
<span style="background:var(--bg-primary);padding:2px 8px;border-radius:10px;font-size:11px">${u.auth_provider}</span>
</td>
<td style="padding:10px 12px">
<span style="color:${u.role === 'admin' ? 'var(--accent)' : 'var(--text-secondary)'}">${u.role}</span>
</td>
<td style="padding:10px 12px">
<select class="input plan-select" data-user-id="${u.id}" style="padding:4px 8px;font-size:12px;width:auto">
${plans.map(p => `<option value="${p.id}" ${u.plan_id === p.id ? 'selected' : ''}>${p.display_name}</option>`).join('')}
</select>
</td>
<td style="padding:10px 12px">
${u.id !== currentUser.id ? `<button class="btn btn-danger btn-sm delete-user-btn" data-user-id="${u.id}">Remove</button>` : '<span style="color:var(--text-muted);font-size:11px">You</span>'}
</td>
</tr>
`).join('')}
</tbody>
</table>
<p style="color:var(--text-muted);font-size:11px;margin-top:12px">${users.length} user(s) registered</p>
`;
// Plan change handlers
el.querySelectorAll('.plan-select').forEach(select => {
select.addEventListener('change', async () => {
const userId = select.dataset.userId;
const planId = select.value;
try {
await api.assignPlan(userId, planId);
showToast('Plan updated', 'success');
} catch (err) {
showToast(err.message, 'error');
loadUsers(); // Revert
}
});
});
// Delete user handlers
el.querySelectorAll('.delete-user-btn').forEach(btn => {
let confirming = false;
btn.addEventListener('click', async () => {
if (confirming) {
try {
await api.deleteUser(btn.dataset.userId);
showToast('User removed', 'success');
loadUsers();
} catch (err) {
showToast(err.message, 'error');
}
return;
}
confirming = true;
btn.textContent = 'Confirm?';
btn.style.background = 'var(--danger)';
btn.style.color = 'white';
setTimeout(() => {
confirming = false;
btn.textContent = 'Remove';
btn.style.background = '';
btn.style.color = '';
}, 3000);
});
});
} catch (err) {
el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`;
}
}
export function cleanup() {}

202
frontend/js/views/teams.js Normal file
View file

@ -0,0 +1,202 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.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());
export async function render(container) {
const hash = window.location.hash;
if (hash.startsWith('#/team/')) {
const id = hash.split('#/team/')[1];
return renderTeamDetail(container, id);
}
return renderList(container);
}
async function renderList(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>Teams <span class="help-tip" data-tip="Create teams to share devices with other users. Owners manage the team, editors can change content/playlists, viewers can only monitor.">?</span></h1><div class="subtitle">Manage teams and shared access</div></div>
<button class="btn btn-primary" id="newTeamBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Team
</button>
</div>
<div id="teamsList"></div>
`;
document.getElementById('newTeamBtn').onclick = async () => {
const name = prompt('Team name:');
if (!name) return;
const team = await API('/teams', { method: 'POST', body: JSON.stringify({ name }) });
window.location.hash = `#/team/${team.id}`;
};
try {
const teams = await API('/teams');
const list = document.getElementById('teamsList');
if (!teams.length) {
list.innerHTML = '<div class="empty-state"><h3>No teams yet</h3><p>Create a team to share devices with other users.</p></div>';
return;
}
list.innerHTML = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px">
${teams.map(t => `
<div class="content-item" style="cursor:pointer" onclick="window.location.hash='#/team/${t.id}'">
<div style="padding:20px">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<div style="width:40px;height:40px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:white">${t.name[0].toUpperCase()}</div>
<div>
<div style="font-weight:600;font-size:16px">${t.name}</div>
<div style="font-size:12px;color:var(--text-muted)">Your role: ${t.my_role} &middot; ${t.member_count} member(s)</div>
</div>
</div>
</div>
</div>
`).join('')}
</div>`;
} catch (err) { showToast(err.message, 'error'); }
}
async function renderTeamDetail(container, teamId) {
let team, devices, allDevices;
try {
[team, devices, allDevices] = await Promise.all([
API(`/teams/${teamId}`),
API(`/teams/${teamId}/devices`),
api.getDevices()
]);
} catch { container.innerHTML = '<div class="empty-state"><h3>Team not found</h3></div>'; return; }
const unassignedDevices = allDevices.filter(d => !d.team_id || d.team_id !== teamId);
container.innerHTML = `
<a href="#/teams" 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>
Back to Teams
</a>
<div class="page-header">
<h1>${team.name}</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-danger btn-sm" id="deleteTeamBtn">Delete Team</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
<!-- Members -->
<div class="settings-section" style="margin:0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="font-size:15px">Members (${team.members?.length || 0})</h3>
<button class="btn btn-secondary btn-sm" id="inviteMemberBtn">+ Invite</button>
</div>
<div id="membersList">
${(team.members || []).map(m => `
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
<div style="width:32px;height:32px;border-radius:50%;background:var(--bg-input);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;color:var(--text-secondary)">${(m.user_name || m.email)[0].toUpperCase()}</div>
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500">${m.user_name || m.email}</div>
<div style="font-size:11px;color:var(--text-muted)">${m.email}</div>
</div>
<select class="input" style="width:100px;background:var(--bg-input);font-size:12px;padding:4px 8px" data-member-id="${m.user_id}" ${m.role === 'owner' ? 'disabled' : ''}>
<option value="viewer" ${m.role === 'viewer' ? 'selected' : ''}>Viewer</option>
<option value="editor" ${m.role === 'editor' ? 'selected' : ''}>Editor</option>
<option value="owner" ${m.role === 'owner' ? 'selected' : ''}>Owner</option>
</select>
${m.role !== 'owner' ? `<button class="btn-icon" data-remove-member="${m.user_id}" style="color:var(--danger)" title="Remove">&#10005;</button>` : ''}
</div>
`).join('') || '<p style="color:var(--text-muted);font-size:13px">No members yet</p>'}
</div>
</div>
<!-- Devices -->
<div class="settings-section" style="margin:0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="font-size:15px">Shared Devices (${devices.length})</h3>
<select id="addDeviceToTeam" class="input" style="width:200px;background:var(--bg-input);font-size:12px">
<option value="">+ Add device...</option>
${unassignedDevices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')}
</select>
</div>
<div id="teamDevicesList">
${devices.map(d => `
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
<span class="status-dot ${d.status}"></span>
<div style="flex:1">
<div style="font-size:13px;font-weight:500">${d.name}</div>
<div style="font-size:11px;color:var(--text-muted)">${d.status}</div>
</div>
<button class="btn-icon" data-remove-device="${d.id}" style="color:var(--danger)" title="Remove from team">&#10005;</button>
</div>
`).join('') || '<p style="color:var(--text-muted);font-size:13px">No devices shared with this team</p>'}
</div>
</div>
</div>
`;
// Invite member
document.getElementById('inviteMemberBtn').onclick = async () => {
const email = prompt('Email address to invite:');
if (!email) return;
const role = prompt('Role (viewer, editor, or owner):', 'editor');
if (!['viewer', 'editor', 'owner'].includes(role)) { showToast('Invalid role', 'error'); return; }
try {
await API(`/teams/${teamId}/invite`, { method: 'POST', body: JSON.stringify({ email, role }) });
showToast('Invitation sent', 'success');
renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); }
};
// Change member role
container.querySelectorAll('[data-member-id]').forEach(select => {
select.onchange = async () => {
try {
await API(`/teams/${teamId}/members/${select.dataset.memberId}`, { method: 'PUT', body: JSON.stringify({ role: select.value }) });
showToast('Role updated', 'success');
} catch (err) { showToast(err.message, 'error'); }
};
});
// Remove member
container.querySelectorAll('[data-remove-member]').forEach(btn => {
btn.onclick = async () => {
try {
await API(`/teams/${teamId}/members/${btn.dataset.removeMember}`, { method: 'DELETE' });
showToast('Member removed', 'success');
renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); }
};
});
// Add device to team
document.getElementById('addDeviceToTeam').onchange = async (e) => {
const deviceId = e.target.value;
if (!deviceId) return;
try {
await API(`/teams/${teamId}/devices`, { method: 'POST', body: JSON.stringify({ device_id: deviceId }) });
showToast('Device added to team', 'success');
renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); }
};
// Remove device from team
container.querySelectorAll('[data-remove-device]').forEach(btn => {
btn.onclick = async () => {
try {
await API(`/teams/${teamId}/devices/${btn.dataset.removeDevice}`, { method: 'DELETE' });
showToast('Device removed from team', 'success');
renderTeamDetail(container, teamId);
} catch (err) { showToast(err.message, 'error'); }
};
});
// Delete team
document.getElementById('deleteTeamBtn').onclick = async () => {
try {
await API(`/teams/${teamId}`, { method: 'DELETE' });
showToast('Team deleted', 'success');
window.location.hash = '#/teams';
} catch (err) { showToast(err.message, 'error'); }
};
}
export function cleanup() {}

View file

@ -0,0 +1,213 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.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());
export async function render(container) {
const hash = window.location.hash;
if (hash.startsWith('#/wall/')) {
const id = hash.split('#/wall/')[1];
return renderWallEditor(container, id);
}
return renderList(container);
}
async function renderList(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>Video Walls <span class="help-tip" data-tip="Combine multiple displays into one large screen. Set grid size, drag devices into positions, adjust bezel compensation. Assign content to play across all devices.">?</span></h1><div class="subtitle">Combine multiple displays into one large screen</div></div>
<button class="btn btn-primary" id="newWallBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Video Wall
</button>
</div>
<div class="content-grid" id="wallGrid"></div>
`;
document.getElementById('newWallBtn').onclick = async () => {
const name = prompt('Video wall name:');
if (!name) return;
const wall = await API('/walls', { method: 'POST', body: JSON.stringify({ name }) });
window.location.hash = `#/wall/${wall.id}`;
};
try {
const walls = await API('/walls');
const grid = document.getElementById('wallGrid');
if (!walls.length) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><h3>No video walls yet</h3><p>Create a video wall to combine multiple displays.</p></div>';
return;
}
grid.innerHTML = walls.map(w => `
<div class="content-item" style="cursor:pointer" onclick="window.location.hash='#/wall/${w.id}'">
<div class="content-item-preview" style="display:flex;align-items:center;justify-content:center;background:var(--bg-primary)">
<div style="display:grid;grid-template-columns:repeat(${w.grid_cols},1fr);gap:3px;width:60%;aspect-ratio:${w.grid_cols}/${w.grid_rows}">
${Array.from({ length: w.grid_cols * w.grid_rows }, (_, i) => {
const row = Math.floor(i / w.grid_cols);
const col = i % w.grid_cols;
const dev = w.devices?.find(d => d.grid_col === col && d.grid_row === row);
return `<div style="background:${dev ? 'rgba(59,130,246,0.3)' : 'var(--bg-card)'};border:1px solid ${dev ? 'var(--accent)' : 'var(--border)'};border-radius:2px;display:flex;align-items:center;justify-content:center;font-size:8px;color:var(--text-muted);aspect-ratio:16/9">${dev?.device_name?.slice(0, 6) || ''}</div>`;
}).join('')}
</div>
</div>
<div class="content-item-body">
<div class="content-item-name">${w.name}</div>
<div class="content-item-size">${w.grid_cols}x${w.grid_rows} grid ${w.devices?.length || 0} devices</div>
</div>
</div>
`).join('');
} catch (err) { showToast(err.message, 'error'); }
}
async function renderWallEditor(container, wallId) {
let wall, devices;
try {
[wall, devices] = await Promise.all([API(`/walls/${wallId}`), api.getDevices()]);
} catch { container.innerHTML = '<div class="empty-state"><h3>Wall not found</h3></div>'; return; }
const content = await api.getContent();
const unassigned = devices.filter(d => !wall.devices?.find(wd => wd.device_id === d.id));
container.innerHTML = `
<a href="#/walls" 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>
Back to Video Walls
</a>
<div class="page-header">
<h1>${wall.name}</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-danger btn-sm" id="deleteWallBtn">Delete Wall</button>
</div>
</div>
<div style="display:flex;gap:24px">
<div style="flex:1">
<h3 style="font-size:14px;margin-bottom:12px">Grid Configuration</h3>
<div style="display:flex;gap:12px;margin-bottom:16px">
<div class="form-group" style="margin:0"><label>Columns</label><input type="number" id="gridCols" class="input" value="${wall.grid_cols}" min="1" max="10" style="width:80px"></div>
<div class="form-group" style="margin:0"><label>Rows</label><input type="number" id="gridRows" class="input" value="${wall.grid_rows}" min="1" max="10" style="width:80px"></div>
<div class="form-group" style="margin:0"><label>H Bezel (mm)</label><input type="number" id="bezelH" class="input" value="${wall.bezel_h_mm}" min="0" step="0.5" style="width:80px"></div>
<div class="form-group" style="margin:0"><label>V Bezel (mm)</label><input type="number" id="bezelV" class="input" value="${wall.bezel_v_mm}" min="0" step="0.5" style="width:80px"></div>
<button class="btn btn-primary btn-sm" id="updateGridBtn" style="align-self:flex-end">Update</button>
</div>
<div id="wallGrid" style="display:inline-grid;gap:4px;background:var(--bg-primary);padding:16px;border:1px solid var(--border);border-radius:var(--radius-lg)"></div>
<h3 style="font-size:14px;margin:24px 0 12px">Content</h3>
<select id="wallContent" class="input" style="width:300px;background:var(--bg-input)">
<option value="">No content</option>
${content.filter(c => c.mime_type?.startsWith('video/')).map(c => `<option value="${c.id}" ${c.id === wall.content_id ? 'selected' : ''}>${c.filename}</option>`).join('')}
</select>
<button class="btn btn-primary btn-sm" id="setContentBtn" style="margin-left:8px">Set Content</button>
</div>
<div style="width:250px">
<h3 style="font-size:14px;margin-bottom:12px">Available Displays</h3>
<div id="availableDevices">
${unassigned.map(d => `
<div class="playlist-item" style="cursor:grab;margin-bottom:4px" draggable="true" data-device-id="${d.id}" data-device-name="${d.name}">
<div class="playlist-item-info">
<div class="playlist-item-name">${d.name}</div>
<div class="playlist-item-meta"><span class="status-dot ${d.status}" style="display:inline-block"></span> ${d.status}</div>
</div>
</div>
`).join('') || '<p style="color:var(--text-muted);font-size:12px">All devices assigned</p>'}
</div>
</div>
</div>
`;
function renderGrid() {
const cols = parseInt(document.getElementById('gridCols').value) || 2;
const rows = parseInt(document.getElementById('gridRows').value) || 2;
const grid = document.getElementById('wallGrid');
grid.style.gridTemplateColumns = `repeat(${cols}, 120px)`;
let html = '';
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const dev = wall.devices?.find(d => d.grid_col === c && d.grid_row === r);
html += `
<div style="width:120px;aspect-ratio:16/9;background:${dev ? 'rgba(59,130,246,0.2)' : 'var(--bg-card)'};
border:2px ${dev ? 'solid var(--accent)' : '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)"
data-grid-col="${c}" data-grid-row="${r}">
${dev ? `<div style="font-weight:500">${dev.device_name}</div><div style="font-size:9px;color:var(--text-muted)">[${c},${r}]</div>` :
`<div style="color:var(--text-muted)">Drop here</div><div style="font-size:9px">[${c},${r}]</div>`}
</div>
`;
}
}
grid.innerHTML = html;
// Drop targets
grid.querySelectorAll('[data-grid-col]').forEach(cell => {
cell.ondragover = (e) => { e.preventDefault(); cell.style.borderColor = 'var(--success)'; };
cell.ondragleave = () => { cell.style.borderColor = ''; };
cell.ondrop = async (e) => {
e.preventDefault();
cell.style.borderColor = '';
const deviceId = e.dataTransfer.getData('device-id');
const deviceName = e.dataTransfer.getData('device-name');
const col = parseInt(cell.dataset.gridCol);
const row = parseInt(cell.dataset.gridRow);
// Add to wall devices
const existing = wall.devices?.filter(d => !(d.grid_col === col && d.grid_row === row)) || [];
existing.push({ device_id: deviceId, device_name: deviceName, grid_col: col, grid_row: row });
try {
const updated = await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: existing }) });
wall.devices = updated.devices;
renderGrid();
showToast(`${deviceName} placed at [${col},${row}]`, 'success');
} catch (err) { showToast(err.message, 'error'); }
};
});
}
// Drag sources
container.querySelectorAll('[draggable]').forEach(el => {
el.ondragstart = (e) => {
e.dataTransfer.setData('device-id', el.dataset.deviceId);
e.dataTransfer.setData('device-name', el.dataset.deviceName);
};
});
document.getElementById('updateGridBtn').onclick = async () => {
try {
await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({
grid_cols: parseInt(document.getElementById('gridCols').value),
grid_rows: parseInt(document.getElementById('gridRows').value),
bezel_h_mm: parseFloat(document.getElementById('bezelH').value),
bezel_v_mm: parseFloat(document.getElementById('bezelV').value),
})});
wall.grid_cols = parseInt(document.getElementById('gridCols').value);
wall.grid_rows = parseInt(document.getElementById('gridRows').value);
renderGrid();
showToast('Grid updated', 'success');
} catch (err) { showToast(err.message, 'error'); }
};
document.getElementById('setContentBtn').onclick = async () => {
const contentId = document.getElementById('wallContent').value;
try {
await API(`/walls/${wallId}/content`, { method: 'PUT', body: JSON.stringify({ content_id: contentId || null }) });
showToast('Content updated', 'success');
} catch (err) { showToast(err.message, 'error'); }
};
document.getElementById('deleteWallBtn').onclick = async () => {
try {
await API(`/walls/${wallId}`, { method: 'DELETE' });
showToast('Wall deleted', 'success');
window.location.hash = '#/walls';
} catch (err) { showToast(err.message, 'error'); }
};
renderGrid();
}
export function cleanup() {}

View file

@ -0,0 +1,216 @@
import { showToast } from '../components/toast.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 WIDGET_TYPES = [
{ id: 'clock', name: 'Clock', icon: '&#128339;', desc: 'Digital clock with date' },
{ id: 'weather', name: 'Weather', icon: '&#9925;', desc: 'Current weather conditions' },
{ id: 'rss', name: 'News Ticker', icon: '&#128240;', desc: 'Scrolling RSS feed' },
{ id: 'text', name: 'Text/HTML', icon: '&#128221;', desc: 'Custom text or HTML content' },
{ id: 'webpage', name: 'Webpage', icon: '&#127760;', desc: 'Embed a webpage' },
{ id: 'social', name: 'Social Feed', icon: '&#128172;', desc: 'Social media feed' },
];
export async function render(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>Widgets <span class="help-tip" data-tip="Dynamic content elements: live clocks, weather, RSS tickers, text, webpages, and social feeds. Create a widget then assign it to a device playlist.">?</span></h1><div class="subtitle">Add dynamic content to your layouts</div></div>
<button class="btn btn-primary" id="newWidgetBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Widget
</button>
</div>
<div id="widgetTypeGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:24px;display:none">
${WIDGET_TYPES.map(t => `
<div class="content-item" style="cursor:pointer" data-create-type="${t.id}">
<div style="padding:20px;text-align:center">
<div style="font-size:36px;margin-bottom:8px">${t.icon}</div>
<div style="font-weight:600;font-size:14px">${t.name}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t.desc}</div>
</div>
</div>
`).join('')}
</div>
<div class="content-grid" id="widgetGrid"></div>
<!-- Widget Config Modal -->
<div class="modal-overlay" id="widgetModal" style="display:none">
<div class="modal" style="width:560px">
<div class="modal-header"><h3 id="widgetModalTitle">Configure Widget</h3>
<button class="btn-icon" onclick="document.getElementById('widgetModal').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>
</button>
</div>
<div class="modal-body" id="widgetConfigForm"></div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="document.getElementById('widgetModal').style.display='none'">Cancel</button>
<button class="btn btn-secondary" id="previewWidgetBtn">Preview</button>
<button class="btn btn-primary" id="saveWidgetBtn">Save</button>
</div>
</div>
</div>
`;
let editingWidget = null;
let creatingType = null;
document.getElementById('newWidgetBtn').onclick = () => {
const grid = document.getElementById('widgetTypeGrid');
grid.style.display = grid.style.display === 'none' ? 'grid' : 'none';
};
container.querySelectorAll('[data-create-type]').forEach(el => {
el.onclick = () => {
creatingType = el.dataset.createType;
editingWidget = null;
document.getElementById('widgetTypeGrid').style.display = 'none';
showConfigForm(creatingType, {});
};
});
function showConfigForm(type, config) {
const typeName = WIDGET_TYPES.find(t => t.id === type)?.name || type;
document.getElementById('widgetModalTitle').textContent = editingWidget ? `Edit ${typeName}` : `New ${typeName}`;
let html = '<div class="form-group"><label>Widget Name</label><input type="text" id="wName" class="input" value="' + (config._name || typeName) + '"></div>';
switch (type) {
case 'clock':
html += `
<div class="form-group"><label>Format</label><select id="wFormat" class="input" style="background:var(--bg-input)"><option value="12h" ${config.format === '12h' ? 'selected' : ''}>12 Hour</option><option value="24h" ${config.format === '24h' ? 'selected' : ''}>24 Hour</option></select></div>
<div class="form-group"><label>Timezone</label><input type="text" id="wTimezone" class="input" value="${config.timezone || 'America/Chicago'}" placeholder="America/New_York"></div>
<div class="form-group"><label>Font Size (px)</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 64}"></div>
<div class="form-group"><label>Color</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div>
<div class="form-group"><label>Background</label><input type="color" id="wBg" value="${config.background || '#000000'}" style="width:60px;height:32px;border:none"></div>`;
break;
case 'weather':
html += `
<div class="form-group"><label>Location</label><input type="text" id="wLocation" class="input" value="${config.location || ''}" placeholder="City, State"></div>
<div class="form-group"><label>Units</label><select id="wUnits" class="input" style="background:var(--bg-input)"><option value="imperial" ${config.units !== 'metric' ? 'selected' : ''}>Imperial (°F)</option><option value="metric" ${config.units === 'metric' ? 'selected' : ''}>Metric (°C)</option></select></div>
<div class="form-group"><label>Font Size</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 48}"></div>
<div class="form-group"><label>Color</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div>`;
break;
case 'rss':
html += `
<div class="form-group"><label>Feed URL</label><input type="text" id="wFeedUrl" class="input" value="${config.feed_url || ''}" placeholder="https://example.com/feed.xml"></div>
<div class="form-group"><label>Scroll Speed (seconds)</label><input type="number" id="wScrollSpeed" class="input" value="${config.scroll_speed || 30}"></div>
<div class="form-group"><label>Max Items</label><input type="number" id="wMaxItems" class="input" value="${config.max_items || 10}"></div>
<div class="form-group"><label>Font Size</label><input type="number" id="wFontSize" class="input" value="${config.font_size || 24}"></div>
<div class="form-group"><label>Color</label><input type="color" id="wColor" value="${config.color || '#FFFFFF'}" style="width:60px;height:32px;border:none"></div>
<div class="form-group"><label>Background</label><input type="color" id="wBg" value="${config.background || '#000000'}" style="width:60px;height:32px;border:none"></div>`;
break;
case 'text':
html += `
<div class="form-group"><label>HTML Content</label><textarea id="wHtml" class="input" rows="6" style="font-family:monospace;font-size:12px">${config.html || '<h1 style="color:white;text-align:center;margin-top:40px">Hello World</h1>'}</textarea></div>
<div class="form-group"><label>CSS (optional)</label><textarea id="wCss" class="input" rows="3" style="font-family:monospace;font-size:12px">${config.css || ''}</textarea></div>
<div class="form-group"><label>Background</label><input type="color" id="wBg" value="${config.background || '#000000'}" style="width:60px;height:32px;border:none"></div>`;
break;
case 'webpage':
html += `
<div class="form-group"><label>URL</label><input type="text" id="wUrl" class="input" value="${config.url || ''}" placeholder="https://example.com"></div>
<div class="form-group"><label>Zoom (%)</label><input type="number" id="wZoom" class="input" value="${config.zoom || 100}"></div>
<div class="form-group"><label>Refresh Interval (seconds, 0 = never)</label><input type="number" id="wRefresh" class="input" value="${config.refresh_interval || 0}"></div>`;
break;
case 'social':
html += `
<div class="form-group"><label>Platform</label><select id="wPlatform" class="input" style="background:var(--bg-input)"><option value="twitter">Twitter/X</option><option value="instagram">Instagram</option></select></div>
<div class="form-group"><label>Query</label><input type="text" id="wQuery" class="input" value="${config.query || ''}" placeholder="@handle or #hashtag"></div>`;
break;
}
document.getElementById('widgetConfigForm').innerHTML = html;
document.getElementById('widgetModal').style.display = 'flex';
}
function getConfigFromForm(type) {
const config = {};
const val = id => document.getElementById(id)?.value;
switch (type) {
case 'clock': Object.assign(config, { format: val('wFormat'), timezone: val('wTimezone'), font_size: parseInt(val('wFontSize')) || 64, color: val('wColor'), background: val('wBg'), show_date: true }); break;
case 'weather': Object.assign(config, { location: val('wLocation'), units: val('wUnits'), font_size: parseInt(val('wFontSize')) || 48, color: val('wColor') }); break;
case 'rss': Object.assign(config, { feed_url: val('wFeedUrl'), scroll_speed: parseInt(val('wScrollSpeed')) || 30, max_items: parseInt(val('wMaxItems')) || 10, font_size: parseInt(val('wFontSize')) || 24, color: val('wColor'), background: val('wBg') }); break;
case 'text': Object.assign(config, { html: val('wHtml'), css: val('wCss'), background: val('wBg') }); break;
case 'webpage': Object.assign(config, { url: val('wUrl'), zoom: parseInt(val('wZoom')) || 100, refresh_interval: parseInt(val('wRefresh')) || 0 }); break;
case 'social': Object.assign(config, { platform: val('wPlatform'), query: val('wQuery') }); break;
}
return config;
}
document.getElementById('saveWidgetBtn').onclick = async () => {
const type = editingWidget?.widget_type || creatingType;
const name = document.getElementById('wName').value;
const config = getConfigFromForm(type);
try {
if (editingWidget) {
await API(`/widgets/${editingWidget.id}`, { method: 'PUT', body: JSON.stringify({ name, config }) });
} else {
await API('/widgets', { method: 'POST', body: JSON.stringify({ widget_type: type, name, config }) });
}
document.getElementById('widgetModal').style.display = 'none';
showToast('Widget saved', 'success');
loadWidgets();
} catch (err) { showToast(err.message, 'error'); }
};
document.getElementById('previewWidgetBtn').onclick = () => {
if (editingWidget) {
window.open(`/api/widgets/${editingWidget.id}/render`, '_blank', 'width=600,height=400');
} else {
showToast('Save the widget first to preview', 'info');
}
};
async function loadWidgets() {
const widgets = await API('/widgets');
const grid = document.getElementById('widgetGrid');
if (!widgets.length) {
grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><h3>No widgets yet</h3><p>Create a widget to add dynamic content to your layouts.</p></div>';
return;
}
grid.innerHTML = widgets.map(w => {
const typeMeta = WIDGET_TYPES.find(t => t.id === w.widget_type) || {};
return `
<div class="content-item">
<div class="content-item-preview" style="display:flex;align-items:center;justify-content:center;flex-direction:column;gap:4px">
<span style="font-size:36px">${typeMeta.icon || '?'}</span>
</div>
<div class="content-item-body">
<div class="content-item-name">${w.name}</div>
<div class="content-item-size">${typeMeta.name || w.widget_type}</div>
</div>
<div class="content-item-actions">
<button class="btn btn-secondary btn-sm" data-edit-widget="${w.id}">Edit</button>
<button class="btn btn-danger btn-sm" data-delete-widget="${w.id}">Delete</button>
</div>
</div>
`;
}).join('');
grid.onclick = async (e) => {
const editBtn = e.target.closest('[data-edit-widget]');
if (editBtn) {
const w = widgets.find(x => x.id === editBtn.dataset.editWidget);
if (w) {
editingWidget = w;
creatingType = w.widget_type;
const config = JSON.parse(w.config || '{}');
config._name = w.name;
showConfigForm(w.widget_type, config);
}
return;
}
const deleteBtn = e.target.closest('[data-delete-widget]');
if (deleteBtn) {
try {
await API(`/widgets/${deleteBtn.dataset.deleteWidget}`, { method: 'DELETE' });
showToast('Widget deleted', 'success');
loadWidgets();
} catch (err) { showToast(err.message, 'error'); }
}
};
}
loadWidgets();
}
export function cleanup() {}

390
frontend/landing.html Normal file
View file

@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary SEO -->
<title>ScreenTinker - Digital Signage Software | Manage Any Screen Remotely</title>
<meta name="description" content="Free digital signage software for any screen. Remote control, video walls, multi-zone layouts, scheduling, kiosk mode, and analytics. Works on Android, Raspberry Pi, Windows, ChromeOS, and smart TVs. Start free, no credit card required.">
<meta name="keywords" content="digital signage, digital signage software, remote display, signage management, video wall, kiosk mode, screen management, content management, Android signage, Raspberry Pi signage, free digital signage">
<meta name="author" content="ScreenTinker">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://screentinker.com/">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://screentinker.com/">
<meta property="og:title" content="ScreenTinker - Digital Signage Made Simple">
<meta property="og:description" content="Manage content on TVs, displays, and kiosks from anywhere. Remote control, video walls, scheduling, and analytics. 9 platforms supported. Start free.">
<meta property="og:image" content="https://screentinker.com/assets/icon-512.png">
<meta property="og:site_name" content="ScreenTinker">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ScreenTinker - Digital Signage Made Simple">
<meta name="twitter:description" content="Free digital signage software for any screen. Remote control, video walls, layouts, scheduling, kiosk mode. Works on 9 platforms.">
<meta name="twitter:image" content="https://screentinker.com/assets/icon-512.png">
<!-- Theme -->
<meta name="theme-color" content="#111827">
<link rel="icon" href="/assets/icon-192.png">
<link rel="apple-touch-icon" href="/assets/icon-192.png">
<style>
* { 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.6; }
a { color:var(--accent); text-decoration:none; }
/* Nav */
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); }
.nav-links a { color:var(--muted); margin-left:24px; font-size:14px; transition:color 0.2s; }
.nav-links a:hover { color:var(--text); }
.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; }
.btn-outline { background:transparent; color:var(--accent); border:1px solid var(--accent); }
.btn-outline:hover { background:rgba(59,130,246,0.1); }
/* Hero */
.hero { padding:140px 24px 80px; text-align:center; max-width:900px; margin:0 auto; }
.hero h1 { font-size:clamp(36px,5vw,64px); font-weight:800; line-height:1.1; margin-bottom:20px; }
.hero h1 span { background:linear-gradient(135deg,#3b82f6,#8b5cf6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
.hero p { font-size:clamp(16px,2vw,20px); color:var(--muted); max-width:600px; margin:0 auto 32px; }
.hero-btns { display:flex; gap:12px; justify-content:center; flex-wrap:wrap; }
.hero-badge { display:inline-block; background:var(--card); border:1px solid var(--border); border-radius:20px; padding:6px 16px; font-size:13px; color:var(--muted); margin-bottom:24px; }
/* Screenshot */
.screenshot { max-width:1100px; margin:0 auto 80px; padding:0 24px; }
.screenshot img, .screenshot .mock { width:100%; border-radius:12px; border:1px solid var(--border); box-shadow:0 20px 60px rgba(0,0,0,0.5); }
.mock { background:var(--card); aspect-ratio:16/9; display:flex; align-items:center; justify-content:center; color:var(--dim); font-size:18px; }
/* Features */
.features { max-width:1200px; margin:0 auto; padding:80px 24px; }
.features h2 { text-align:center; font-size:36px; margin-bottom:12px; }
.features .subtitle { text-align:center; color:var(--muted); margin-bottom:48px; font-size:18px; }
.feature-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:24px; }
.feature-card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:28px; transition:border-color 0.2s; }
.feature-card:hover { border-color:var(--accent); }
.feature-icon { font-size:32px; margin-bottom:12px; }
.feature-card h3 { font-size:18px; margin-bottom:8px; }
.feature-card p { color:var(--muted); font-size:14px; }
/* Platforms */
.platforms { max-width:1200px; margin:0 auto; padding:80px 24px; text-align:center; }
.platforms h2 { font-size:36px; margin-bottom:12px; }
.platforms .subtitle { color:var(--muted); margin-bottom:40px; font-size:18px; }
.platform-grid { display:flex; justify-content:center; gap:32px; flex-wrap:wrap; }
.platform-item { text-align:center; width:100px; }
.platform-item .icon { font-size:40px; margin-bottom:8px; }
.platform-item .name { font-size:13px; color:var(--muted); }
/* Pricing */
.pricing { max-width:1200px; margin:0 auto; padding:80px 24px; }
.pricing h2 { text-align:center; font-size:36px; margin-bottom:12px; }
.pricing .subtitle { text-align:center; color:var(--muted); margin-bottom:48px; font-size:18px; }
.pricing-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:20px; }
.price-card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:28px; position:relative; }
.price-card.featured { border-color:var(--accent); }
.price-card.featured::before { content:'Most Popular'; position:absolute; top:-12px; left:50%; transform:translateX(-50%); background:var(--accent); color:white; padding:4px 16px; border-radius:12px; font-size:12px; font-weight:600; }
.price-card h3 { font-size:20px; margin-bottom:4px; }
.price-card .price { font-size:36px; font-weight:700; color:var(--accent); margin:12px 0; }
.price-card .price span { font-size:16px; color:var(--muted); font-weight:400; }
.price-card .yearly { font-size:12px; color:var(--dim); margin-bottom:16px; }
.price-card ul { list-style:none; margin-bottom:20px; }
.price-card li { font-size:14px; color:var(--muted); padding:4px 0; }
.price-card li::before { content:'✓ '; color:var(--accent); }
/* Compare */
.compare { max-width:1000px; margin:0 auto; padding:80px 24px; }
.compare h2 { text-align:center; font-size:36px; margin-bottom:40px; }
.compare-table { width:100%; border-collapse:collapse; font-size:14px; }
.compare-table th, .compare-table td { padding:12px 16px; text-align:left; border-bottom:1px solid var(--border); }
.compare-table th { color:var(--dim); font-weight:500; }
.compare-table td:first-child { color:var(--muted); }
.compare-table .yes { color:#22c55e; }
.compare-table .no { color:#ef4444; }
.compare-table .paid { color:#f59e0b; }
/* CTA */
.cta { text-align:center; padding:80px 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:80px 0; }
.cta h2 { font-size:36px; margin-bottom:12px; }
.cta p { color:var(--muted); margin-bottom:24px; font-size:18px; }
/* Footer */
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; }
@media (max-width:768px) {
.nav-links { display:none; }
.feature-grid { grid-template-columns:1fr; }
.pricing-grid { grid-template-columns:1fr; }
.compare-table { font-size:12px; }
.compare-table th, .compare-table td { padding:8px; }
footer { flex-direction:column; text-align:center; }
}
</style>
</head>
<body>
<nav>
<div class="nav-inner">
<div class="nav-logo">
<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>
ScreenTinker
</div>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#platforms">Platforms</a>
<a href="#pricing">Pricing</a>
<a href="#compare">Compare</a>
<a href="/app" class="btn btn-outline" style="margin-left:16px">Sign In</a>
<a href="/app#/login" class="btn btn-primary" style="margin-left:8px">Start Free Trial</a>
</div>
</div>
</nav>
<!-- Hero -->
<section class="hero">
<div class="hero-badge">&#9889; 14-day free Pro trial &middot; No credit card required</div>
<h1>Digital Signage<br>for <span>Any Screen</span></h1>
<p>Manage content on TVs, displays, and kiosks from anywhere. Remote control, video walls, scheduling, and analytics. Works on any device.</p>
<div class="hero-btns">
<a href="/app#/login" class="btn btn-primary" style="padding:14px 28px;font-size:16px">Start Free Trial</a>
<a href="#compare" class="btn btn-outline" style="padding:14px 28px;font-size:16px">See How We Compare</a>
</div>
</section>
<!-- Screenshot placeholder -->
<div class="screenshot">
<div class="mock">
<iframe src="#/" style="width:100%;height:100%;border:none;border-radius:12px;pointer-events:none" loading="lazy"></iframe>
</div>
</div>
<!-- Features -->
<section class="features" id="features">
<h2>Everything You Need</h2>
<p class="subtitle">One platform to manage all your digital signage</p>
<div class="feature-grid">
<div class="feature-card"><div class="feature-icon">&#128250;</div><h3>Multi-Zone Layouts</h3><p>Split screens into zones with a drag-and-drop editor. 7 built-in templates or create your own.</p></div>
<div class="feature-card"><div class="feature-icon">&#127916;</div><h3>Video Wall</h3><p>Combine multiple displays into one giant screen. Automatic bezel compensation. Any grid size.</p></div>
<div class="feature-card"><div class="feature-icon">&#128421;</div><h3>Remote Control</h3><p>See what's on screen in real-time. Send key presses, navigate menus, power on/off remotely.</p></div>
<div class="feature-card"><div class="feature-icon">&#128197;</div><h3>Scheduling</h3><p>Visual weekly calendar. Set content to play at specific times with recurrence rules.</p></div>
<div class="feature-card"><div class="feature-icon">&#128295;</div><h3>Content Designer</h3><p>Built-in editor with live clocks, weather, RSS tickers, countdowns, QR codes, and more.</p></div>
<div class="feature-card"><div class="feature-icon">&#128433;</div><h3>Kiosk Mode</h3><p>Create interactive touchscreen interfaces. Wayfinding, directories, check-in screens.</p></div>
<div class="feature-card"><div class="feature-icon">&#128202;</div><h3>Proof-of-Play</h3><p>Track what played, when, and on which device. Export CSV reports for ad verification.</p></div>
<div class="feature-card"><div class="feature-icon">&#128276;</div><h3>Alerts & Monitoring</h3><p>Email alerts when devices go offline. Full telemetry: battery, storage, WiFi, uptime.</p></div>
<div class="feature-card"><div class="feature-icon">&#128274;</div><h3>Self-Hosted Option</h3><p>Deploy on your own infrastructure. Your data never leaves your network. Full control.</p></div>
<div class="feature-card"><div class="feature-icon">&#127912;</div><h3>White Label</h3><p>Custom branding, colors, logo, and domain. Resell under your own brand.</p></div>
<div class="feature-card"><div class="feature-icon">&#128101;</div><h3>Teams</h3><p>Multi-user accounts with owner, editor, and viewer roles. Invite by email.</p></div>
<div class="feature-card"><div class="feature-icon">&#128260;</div><h3>Auto-Update</h3><p>Devices automatically update when you push a new version. Zero manual intervention.</p></div>
</div>
</section>
<!-- Platforms -->
<section class="platforms" id="platforms">
<h2>Runs on Everything</h2>
<p class="subtitle">No hardware lock-in. Use any screen you already have.</p>
<div class="platform-grid">
<div class="platform-item"><div class="icon">&#129302;</div><div class="name">Android TV</div></div>
<div class="platform-item"><div class="icon">&#128293;</div><div class="name">Fire TV</div></div>
<div class="platform-item"><div class="icon">&#129359;</div><div class="name">Raspberry Pi</div></div>
<div class="platform-item"><div class="icon">&#128187;</div><div class="name">Windows</div></div>
<div class="platform-item"><div class="icon">&#127760;</div><div class="name">ChromeOS</div></div>
<div class="platform-item"><div class="icon">&#128250;</div><div class="name">LG webOS</div></div>
<div class="platform-item"><div class="icon">&#128250;</div><div class="name">Samsung Tizen</div></div>
<div class="platform-item"><div class="icon">&#127758;</div><div class="name">Any Browser</div></div>
</div>
</section>
<!-- Pricing -->
<section class="pricing" id="pricing">
<h2>Simple, Honest Pricing</h2>
<p class="subtitle">All plans include remote control, monitoring, and unlimited content</p>
<div class="pricing-grid" id="pricingGrid"></div>
</section>
<!-- Compare -->
<section class="compare" id="compare">
<h2>How We Compare</h2>
<table class="compare-table">
<thead><tr><th></th><th style="color:var(--accent);font-weight:700">ScreenTinker</th><th>Yodeck</th><th>ScreenCloud</th><th>OptiSigns</th></tr></thead>
<tbody>
<tr><td>Price (15 devices/yr)</td><td style="color:var(--accent);font-weight:600">$989</td><td>$1,440+</td><td>$3,600+</td><td>$1,800+</td></tr>
<tr><td>Free tier</td><td class="yes">&#10003; 1 device</td><td class="yes">&#10003;</td><td class="no">&#10007;</td><td class="yes">&#10003;</td></tr>
<tr><td>Platforms</td><td class="yes">9 platforms</td><td>2</td><td>2</td><td>3</td></tr>
<tr><td>Video Wall</td><td class="yes">&#10003; Included</td><td class="no">&#10007;</td><td class="no">&#10007;</td><td class="paid">Paid add-on</td></tr>
<tr><td>Remote Control</td><td class="yes">&#10003; All plans</td><td class="paid">Paid add-on</td><td class="no">&#10007;</td><td class="no">&#10007;</td></tr>
<tr><td>Content Designer</td><td class="yes">&#10003; Built-in</td><td class="no">&#10007;</td><td class="no">&#10007;</td><td class="no">&#10007;</td></tr>
<tr><td>Kiosk/Touchscreen</td><td class="yes">&#10003; Included</td><td class="no">&#10007;</td><td class="no">&#10007;</td><td class="no">&#10007;</td></tr>
<tr><td>Proof-of-Play</td><td class="yes">&#10003; All plans</td><td class="paid">Paid tier</td><td class="paid">Paid</td><td class="paid">Paid</td></tr>
<tr><td>Self-Hosted</td><td class="yes">&#10003; Only us</td><td class="no">&#10007;</td><td class="no">&#10007;</td><td class="no">&#10007;</td></tr>
<tr><td>White Label</td><td class="yes">&#10003; Included</td><td class="paid">Paid</td><td class="paid">Enterprise</td><td class="no">&#10007;</td></tr>
<tr><td>Email Alerts</td><td class="yes">&#10003; All plans</td><td class="paid">Paid</td><td class="paid">Paid</td><td class="paid">Paid</td></tr>
<tr><td>Hardware Lock-in</td><td class="yes">None</td><td>RPi focused</td><td>Chromecast</td><td>Various</td></tr>
</tbody>
</table>
</section>
<!-- CTA -->
<section class="cta">
<h2>Ready to Get Started?</h2>
<p>14-day free Pro trial. No credit card required. Set up in under 5 minutes.</p>
<a href="/app#/login" class="btn btn-primary" style="padding:14px 32px;font-size:16px">Start Free Trial</a>
</section>
<!-- Footer -->
<footer>
<div style="color:var(--dim);font-size:13px">&copy; 2026 ScreenTinker. All rights reserved.</div>
<div class="links">
<a href="/legal/terms.html">Terms</a>
<a href="/legal/privacy.html">Privacy</a>
<a href="/legal/third-party.html">Licenses</a>
<a href="/api/status" target="_blank">Status</a>
<a href="/app#/login">Sign In</a>
</div>
</footer>
<!-- Structured Data for Google -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "ScreenTinker",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Android, Web, Windows, Linux, ChromeOS",
"description": "Digital signage management software with remote control, video walls, multi-zone layouts, scheduling, kiosk mode, and analytics. Works on 9 platforms.",
"url": "https://screentinker.com",
"offers": [
{
"@type": "Offer",
"price": "0",
"priceCurrency": "USD",
"name": "Free",
"description": "1 device, 500MB storage"
},
{
"@type": "Offer",
"price": "39",
"priceCurrency": "USD",
"priceValidUntil": "2027-12-31",
"name": "Starter",
"description": "5 devices, 5GB storage, remote URL streaming"
},
{
"@type": "Offer",
"price": "99",
"priceCurrency": "USD",
"priceValidUntil": "2027-12-31",
"name": "Pro",
"description": "15 devices, 20GB storage, all features"
}
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"ratingCount": "50"
},
"featureList": [
"Multi-zone screen layouts",
"Video wall support",
"Remote control with live view",
"Content scheduling with calendar",
"Built-in content designer",
"Interactive kiosk/touchscreen mode",
"Proof-of-play analytics",
"Device monitoring and alerts",
"White-label/reseller support",
"Self-hosted option",
"9 platform support",
"Auto-update OTA"
]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "ScreenTinker",
"url": "https://screentinker.com",
"logo": "https://screentinker.com/assets/icon-512.png",
"sameAs": []
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What platforms does ScreenTinker support?",
"acceptedAnswer": {
"@type": "Answer",
"text": "ScreenTinker works on Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, and any device with a web browser."
}
},
{
"@type": "Question",
"name": "Is there a free plan?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes, ScreenTinker offers a free plan with 1 device and 500MB of storage. New accounts also get a 14-day free trial of the Pro plan with 15 devices."
}
},
{
"@type": "Question",
"name": "Can I self-host ScreenTinker?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes, ScreenTinker is the only digital signage platform that offers a self-hosted option. Deploy on your own infrastructure and keep all data on your network."
}
},
{
"@type": "Question",
"name": "Does ScreenTinker support video walls?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes, you can combine multiple displays into a single video wall with automatic bezel compensation. Configure any grid size (2x2, 3x3, etc.)."
}
}
]
}
</script>
<script>
// Load pricing from API
fetch('/api/subscription/plans').then(r => r.json()).then(plans => {
const grid = document.getElementById('pricingGrid');
grid.innerHTML = plans.filter(p => p.active).map((p, i) => `
<div class="price-card ${i === 2 ? 'featured' : ''}">
<h3>${p.display_name}</h3>
<div class="price">${p.price_monthly > 0 ? '$' + p.price_monthly : 'Free'}<span>${p.price_monthly > 0 ? '/mo' : ''}</span></div>
${p.price_yearly > 0 ? `<div class="yearly">or $${p.price_yearly}/year (save ${Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100)}%)</div>` : '<div class="yearly">&nbsp;</div>'}
<ul>
<li>${p.max_devices === -1 ? 'Unlimited' : p.max_devices} device${p.max_devices !== 1 ? 's' : ''}</li>
<li>${p.max_storage_mb === -1 ? 'Unlimited' : p.max_storage_mb >= 1024 ? (p.max_storage_mb / 1024) + ' GB' : p.max_storage_mb + ' MB'} storage</li>
<li>Remote control & live view</li>
${p.remote_url ? '<li>Remote URL streaming</li>' : ''}
${p.priority_support ? '<li>Priority support</li>' : ''}
</ul>
<a href="/app#/login" class="btn ${i === 0 ? 'btn-outline' : 'btn-primary'}" style="width:100%;justify-content:center">${p.price_monthly > 0 ? 'Start Trial' : 'Get Started'}</a>
</div>
`).join('');
});
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(a => {
if (a.getAttribute('href').startsWith('#/')) return; // Skip app routes
a.addEventListener('click', e => {
const target = document.querySelector(a.getAttribute('href'));
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); }
});
});
</script>
</body>
</html>

143
frontend/legal/privacy.html Normal file
View file

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy - ScreenTinker</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,sans-serif; background:#111827; color:#e2e8f0; line-height:1.8; }
.container { max-width:800px; margin:0 auto; padding:40px 24px 80px; }
h1 { color:#3b82f6; font-size:32px; margin-bottom:8px; }
.updated { color:#64748b; font-size:14px; margin-bottom:40px; }
h2 { color:#f1f5f9; font-size:20px; margin:32px 0 12px; }
h3 { color:#cbd5e1; font-size:16px; margin:20px 0 8px; }
p, li { color:#94a3b8; font-size:15px; margin-bottom:12px; }
ul { padding-left:24px; }
a { color:#3b82f6; }
.back { display:inline-flex; align-items:center; gap:6px; color:#64748b; font-size:13px; margin-bottom:24px; text-decoration:none; }
.back:hover { color:#94a3b8; }
table { width:100%; border-collapse:collapse; margin:16px 0; }
th, td { padding:10px 12px; text-align:left; border-bottom:1px solid #1e293b; font-size:14px; color:#94a3b8; }
th { color:#cbd5e1; font-weight:600; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back">&larr; Back to ScreenTinker</a>
<h1>Privacy Policy</h1>
<p class="updated">Last updated: March 24, 2026</p>
<h2>1. Overview</h2>
<p>ScreenTinker ("we", "us", "our") respects your privacy. This policy explains what data we collect, how we use it, and your rights regarding your information.</p>
<h2>2. Information We Collect</h2>
<h3>2.1 Account Information</h3>
<table>
<tr><th>Data</th><th>Purpose</th><th>Retention</th></tr>
<tr><td>Email address</td><td>Authentication, notifications</td><td>Until account deletion</td></tr>
<tr><td>Name</td><td>Display in dashboard</td><td>Until account deletion</td></tr>
<tr><td>Password hash</td><td>Authentication (bcrypt, never stored in plain text)</td><td>Until account deletion</td></tr>
<tr><td>OAuth provider ID</td><td>Google/Microsoft sign-in</td><td>Until account deletion</td></tr>
</table>
<h3>2.2 Device Information</h3>
<table>
<tr><th>Data</th><th>Purpose</th><th>Retention</th></tr>
<tr><td>Device ID</td><td>Unique device identification</td><td>Until device removal</td></tr>
<tr><td>IP address</td><td>Network connectivity, security</td><td>Overwritten each connection</td></tr>
<tr><td>Android version, screen resolution</td><td>Compatibility, display optimization</td><td>Until device removal</td></tr>
<tr><td>Battery, storage, RAM, CPU, WiFi</td><td>Device health monitoring</td><td>90 days (rolling)</td></tr>
<tr><td>Device fingerprint (hardware hash)</td><td>Prevent trial abuse</td><td>Until device removal</td></tr>
</table>
<h3>2.3 Usage Data</h3>
<table>
<tr><th>Data</th><th>Purpose</th><th>Retention</th></tr>
<tr><td>Content play logs</td><td>Proof-of-play reporting</td><td>90 days</td></tr>
<tr><td>Activity log (API actions)</td><td>Audit trail, security</td><td>90 days</td></tr>
<tr><td>Screenshots (on-demand)</td><td>Remote monitoring</td><td>Latest only per device</td></tr>
</table>
<h3>2.4 Content</h3>
<p>Media files (images, videos) you upload are stored on our servers solely to deliver them to your devices. We do not analyze, sell, or share your content.</p>
<h2>3. How We Use Your Information</h2>
<ul>
<li><strong>Provide the Service:</strong> Deliver content to devices, enable remote management, process subscriptions</li>
<li><strong>Security:</strong> Detect unauthorized access, prevent abuse, protect accounts</li>
<li><strong>Communications:</strong> Send device offline alerts, subscription notifications, service updates</li>
<li><strong>Improvement:</strong> Analyze aggregate usage patterns to improve the Service (no individual tracking)</li>
</ul>
<h2>4. Data Sharing</h2>
<p>We do not sell your personal information. We share data only in these limited circumstances:</p>
<ul>
<li><strong>Service providers:</strong> Payment processing (Stripe), email delivery, hosting infrastructure</li>
<li><strong>Team members:</strong> If you belong to a team, other team members can see shared devices and content</li>
<li><strong>Legal requirements:</strong> When required by law, subpoena, or court order</li>
<li><strong>Business transfers:</strong> In the event of a merger, acquisition, or sale of assets</li>
</ul>
<h2>5. Self-Hosted Deployments</h2>
<p>If you self-host ScreenTinker on your own infrastructure:</p>
<ul>
<li>All data stays on your servers. We have no access to it.</li>
<li>You are the data controller and responsible for compliance with applicable privacy laws.</li>
<li>No telemetry or usage data is sent to us from self-hosted instances.</li>
</ul>
<h2>6. Data Security</h2>
<ul>
<li>Passwords are hashed with bcrypt (never stored in plain text)</li>
<li>API authentication uses JWT tokens with auto-expiry</li>
<li>All connections use HTTPS/TLS encryption</li>
<li>Android app uses encrypted storage for credentials</li>
<li>Rate limiting protects against brute force attacks</li>
<li>Regular security audits of the codebase</li>
</ul>
<h2>7. Your Rights</h2>
<p>You have the right to:</p>
<ul>
<li><strong>Access:</strong> View all data associated with your account from the Settings page</li>
<li><strong>Correction:</strong> Update your account information at any time</li>
<li><strong>Deletion:</strong> Delete your account and all associated data from Settings</li>
<li><strong>Export:</strong> Download your data via the database backup feature (admin) or API</li>
<li><strong>Portability:</strong> Export content and reports in standard formats (CSV, PNG, MP4)</li>
</ul>
<h2>8. Cookies and Local Storage</h2>
<ul>
<li>We use localStorage to store your authentication token and preferences (language, theme)</li>
<li>The web player uses a Service Worker for offline content caching</li>
<li>We do not use third-party tracking cookies</li>
<li>Google/Microsoft OAuth may set cookies as part of their authentication flow</li>
</ul>
<h2>9. Children's Privacy</h2>
<p>The Service is not intended for use by children under 13. We do not knowingly collect information from children under 13.</p>
<h2>10. International Data Transfers</h2>
<p>If you access the Service from outside the United States, your data may be transferred to and processed in the United States. By using the Service, you consent to this transfer.</p>
<h2>11. Data Retention</h2>
<ul>
<li>Account data: retained until you delete your account</li>
<li>Device telemetry: 90 days (automatically pruned)</li>
<li>Play logs: 90 days (automatically pruned)</li>
<li>Activity logs: 90 days (automatically pruned)</li>
<li>Content: retained until you delete it or your account</li>
<li>After account deletion: all data removed within 30 days</li>
</ul>
<h2>12. Changes to This Policy</h2>
<p>We may update this policy from time to time. We will notify registered users of material changes via email. The "Last updated" date will be revised accordingly.</p>
<h2>13. Contact Us</h2>
<p>For privacy-related questions or data requests, contact us at:</p>
<p>Email: support@screentinker.com</p>
</div>
</body>
</html>

138
frontend/legal/terms.html Normal file
View file

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terms of Service - ScreenTinker</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,sans-serif; background:#111827; color:#e2e8f0; line-height:1.8; }
.container { max-width:800px; margin:0 auto; padding:40px 24px 80px; }
h1 { color:#3b82f6; font-size:32px; margin-bottom:8px; }
.updated { color:#64748b; font-size:14px; margin-bottom:40px; }
h2 { color:#f1f5f9; font-size:20px; margin:32px 0 12px; }
p, li { color:#94a3b8; font-size:15px; margin-bottom:12px; }
ul { padding-left:24px; }
a { color:#3b82f6; }
.back { display:inline-flex; align-items:center; gap:6px; color:#64748b; font-size:13px; margin-bottom:24px; text-decoration:none; }
.back:hover { color:#94a3b8; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back">&larr; Back to ScreenTinker</a>
<h1>Terms of Service</h1>
<p class="updated">Last updated: March 24, 2026</p>
<h2>1. Acceptance of Terms</h2>
<p>By accessing or using ScreenTinker ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p>
<h2>2. Description of Service</h2>
<p>ScreenTinker is a digital signage management platform that allows users to remotely manage content on display devices. The Service includes a web dashboard, API, Android application, web player, and related tools.</p>
<h2>3. Accounts</h2>
<p>You must provide accurate information when creating an account. You are responsible for maintaining the security of your account credentials. You must notify us immediately of any unauthorized use of your account.</p>
<h2>4. Subscription Plans and Billing</h2>
<ul>
<li>Free accounts are limited to 1 device and 500MB of storage.</li>
<li>New accounts receive a 14-day free trial of the Pro plan.</li>
<li>Paid subscriptions are billed monthly or annually as selected.</li>
<li>You may cancel your subscription at any time. Access continues until the end of the billing period.</li>
<li>Refunds are handled on a case-by-case basis within 30 days of purchase.</li>
<li>We reserve the right to change pricing with 30 days notice to existing subscribers.</li>
</ul>
<h2>5. Acceptable Use</h2>
<p>You agree not to:</p>
<ul>
<li>Use the Service for any illegal purpose or to display illegal content</li>
<li>Upload malware, viruses, or harmful code</li>
<li>Attempt to gain unauthorized access to other users' accounts or devices</li>
<li>Circumvent device limits, trial restrictions, or other usage controls</li>
<li>Resell the Service without a reseller agreement</li>
<li>Use the Service to send unsolicited advertising or spam</li>
<li>Overload the Service infrastructure through automated or abusive means</li>
</ul>
<h2>6. Content</h2>
<ul>
<li>You retain ownership of content you upload to the Service.</li>
<li>You grant us a limited license to store, transmit, and display your content as necessary to provide the Service.</li>
<li>You are solely responsible for ensuring you have the rights to display any content you upload.</li>
<li>We may remove content that violates these terms or applicable law.</li>
</ul>
<h2>7. Device Management</h2>
<ul>
<li>You are responsible for devices you connect to the Service.</li>
<li>The Service collects device telemetry (battery, storage, network status) for monitoring purposes.</li>
<li>Remote control features should only be used on devices you own or have authorization to manage.</li>
</ul>
<h2>8. Self-Hosted Deployments</h2>
<p>If you deploy ScreenTinker on your own infrastructure:</p>
<ul>
<li>You are responsible for server security, backups, and maintenance.</li>
<li>We provide the software as-is without managed hosting guarantees.</li>
<li>License terms still apply to self-hosted deployments.</li>
</ul>
<h2>9. Privacy</h2>
<p>Your use of the Service is also governed by our <a href="/legal/privacy.html">Privacy Policy</a>.</p>
<h2>10. Service Availability</h2>
<ul>
<li>We strive for high availability but do not guarantee uninterrupted service.</li>
<li>We may perform maintenance with reasonable notice when possible.</li>
<li>We are not liable for damages resulting from service interruptions.</li>
</ul>
<h2>11. Intellectual Property</h2>
<p>The ScreenTinker software, dashboard, APIs, and documentation are our intellectual property. Your subscription grants you a license to use the Service, not ownership of the software.</p>
<p>ScreenTinker incorporates open-source software components licensed under MIT and Apache 2.0 licenses. A complete list of third-party software and their licenses is available at our <a href="/legal/third-party.html">Third-Party Software Notices</a> page.</p>
<h2>12. Restrictions</h2>
<p>You agree not to, and will not permit others to:</p>
<ul>
<li>Reverse engineer, decompile, disassemble, or otherwise attempt to derive the source code of the Software</li>
<li>Modify, adapt, translate, or create derivative works based on the Software</li>
<li>Remove, alter, or obscure any proprietary notices, labels, or marks on the Software</li>
<li>Copy, distribute, or sublicense the Software except as expressly permitted by your subscription plan</li>
<li>Use the Software to build a competing product or service</li>
<li>Circumvent or disable any licensing, authentication, or usage-tracking mechanisms in the Software</li>
<li>Share, transfer, or assign your license key or account credentials to unauthorized parties</li>
<li>Operate the Software beyond the scope of your current subscription plan or license agreement</li>
<li>Scrape, crawl, or programmatically extract data from the Software beyond the provided API</li>
</ul>
<p>Violation of these restrictions may result in immediate termination of your account and may subject you to legal action.</p>
<h2>13. License Keys and Self-Hosted Deployments</h2>
<ul>
<li>Self-hosted Enterprise deployments require a valid license key issued by ScreenTinker.</li>
<li>License keys are non-transferable and tied to a specific organization.</li>
<li>License keys are issued for a specific term (monthly or annual) and must be renewed to maintain functionality.</li>
<li>Upon expiration of a license key, the Software will enter a grace period of 14 days, during which functionality is preserved but a renewal notice is displayed.</li>
<li>After the grace period, the Software will operate in a limited mode equivalent to the Free plan until a valid license key is provided.</li>
<li>Tampering with, bypassing, or forging license keys is strictly prohibited and constitutes a material breach of these terms.</li>
<li>We reserve the right to remotely verify license key validity and disable functionality for invalid or expired keys.</li>
</ul>
<h2>14. Termination</h2>
<ul>
<li>You may terminate your account at any time from the Settings page.</li>
<li>We may terminate accounts that violate these terms with or without notice.</li>
<li>Upon termination, your content will be deleted after 30 days.</li>
</ul>
<h2>15. Limitation of Liability</h2>
<p>THE SERVICE IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND. IN NO EVENT SHALL WE BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING FROM YOUR USE OF THE SERVICE.</p>
<h2>16. Changes to Terms</h2>
<p>We may update these terms from time to time. We will notify registered users of material changes via email. Continued use of the Service after changes constitutes acceptance.</p>
<h2>17. Contact</h2>
<p>For questions about these terms, contact us at support@screentinker.com</p>
</div>
</body>
</html>

View file

@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Third-Party Licenses - ScreenTinker</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,sans-serif; background:#111827; color:#e2e8f0; line-height:1.8; }
.container { max-width:800px; margin:0 auto; padding:40px 24px 80px; }
h1 { color:#3b82f6; font-size:32px; margin-bottom:8px; }
.updated { color:#64748b; font-size:14px; margin-bottom:40px; }
h2 { color:#f1f5f9; font-size:18px; margin:32px 0 8px; }
p, li { color:#94a3b8; font-size:14px; margin-bottom:8px; }
a { color:#3b82f6; }
.back { display:inline-flex; align-items:center; gap:6px; color:#64748b; font-size:13px; margin-bottom:24px; text-decoration:none; }
.back:hover { color:#94a3b8; }
.license-block { background:#0f172a; border:1px solid #1e293b; border-radius:8px; padding:16px; margin:12px 0 24px; font-size:12px; font-family:monospace; white-space:pre-wrap; color:#64748b; overflow-x:auto; }
table { width:100%; border-collapse:collapse; margin:16px 0 32px; }
th, td { padding:10px 12px; text-align:left; border-bottom:1px solid #1e293b; font-size:13px; color:#94a3b8; }
th { color:#cbd5e1; font-weight:600; }
</style>
</head>
<body>
<div class="container">
<a href="/" class="back">&larr; Back to ScreenTinker</a>
<h1>Third-Party Software Notices</h1>
<p class="updated">Last updated: March 24, 2026</p>
<p>ScreenTinker uses the following open-source software components. We gratefully acknowledge the contributions of these projects and their maintainers.</p>
<h2>Summary</h2>
<table>
<thead><tr><th>Package</th><th>License</th><th>Use</th></tr></thead>
<tbody>
<tr><td>Express</td><td>MIT</td><td>Web server framework</td></tr>
<tr><td>Socket.IO</td><td>MIT</td><td>Real-time WebSocket communication</td></tr>
<tr><td>better-sqlite3</td><td>MIT</td><td>SQLite database driver</td></tr>
<tr><td>Multer</td><td>MIT</td><td>File upload handling</td></tr>
<tr><td>uuid</td><td>MIT</td><td>Unique ID generation</td></tr>
<tr><td>Sharp</td><td>Apache 2.0</td><td>Image processing and thumbnails</td></tr>
<tr><td>cors</td><td>MIT</td><td>Cross-origin resource sharing</td></tr>
<tr><td>bcryptjs</td><td>MIT</td><td>Password hashing</td></tr>
<tr><td>jsonwebtoken</td><td>MIT</td><td>JWT authentication tokens</td></tr>
<tr><td>Helmet</td><td>MIT</td><td>HTTP security headers</td></tr>
<tr><td>google-auth-library</td><td>Apache 2.0</td><td>Google OAuth verification</td></tr>
<tr><td>OkHttp</td><td>Apache 2.0</td><td>Android HTTP client</td></tr>
<tr><td>Gson</td><td>Apache 2.0</td><td>Android JSON parsing</td></tr>
<tr><td>AndroidX Media3 / ExoPlayer</td><td>Apache 2.0</td><td>Android video playback</td></tr>
<tr><td>AndroidX libraries</td><td>Apache 2.0</td><td>Android UI and lifecycle</td></tr>
<tr><td>Material Components for Android</td><td>Apache 2.0</td><td>Android UI components</td></tr>
<tr><td>Socket.IO Java Client</td><td>MIT</td><td>Android WebSocket client</td></tr>
<tr><td>Kotlin Coroutines</td><td>Apache 2.0</td><td>Android async operations</td></tr>
<tr><td>AndroidX Security Crypto</td><td>Apache 2.0</td><td>Encrypted SharedPreferences</td></tr>
<tr><td>AndroidX WorkManager</td><td>Apache 2.0</td><td>Background task management</td></tr>
</tbody>
</table>
<h2>MIT License</h2>
<p>The following packages are licensed under the MIT License:</p>
<p>Express, Socket.IO, better-sqlite3, Multer, uuid, cors, bcryptjs, jsonwebtoken, Helmet, Socket.IO Java Client</p>
<div class="license-block">MIT License
Copyright (c) respective authors and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.</div>
<h2>Apache License 2.0</h2>
<p>The following packages are licensed under the Apache License, Version 2.0:</p>
<p>Sharp, google-auth-library, OkHttp, Gson, AndroidX Media3/ExoPlayer, AndroidX libraries, Material Components for Android, Kotlin Coroutines, AndroidX Security Crypto, AndroidX WorkManager</p>
<div class="license-block">Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.</div>
<h2>Contact</h2>
<p>If you have questions about the licensing of any component used in ScreenTinker, please contact us at support@screentinker.com</p>
</div>
</body>
</html>

22
frontend/manifest.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "ScreenTinker",
"short_name": "ScreenTinker",
"description": "Digital Signage Management",
"start_url": "/",
"display": "standalone",
"background_color": "#111827",
"theme_color": "#3B82F6",
"orientation": "any",
"icons": [
{
"src": "/assets/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/assets/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

11
frontend/robots.txt Normal file
View file

@ -0,0 +1,11 @@
User-agent: *
Allow: /
Allow: /legal/
Allow: /player/
Disallow: /api/
Disallow: /app
Disallow: /uploads/
Disallow: /scripts/
Sitemap: https://screentinker.com/sitemap.xml

18
frontend/sitemap.xml Normal file
View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://screentinker.com/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://screentinker.com/legal/terms.html</loc>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://screentinker.com/legal/privacy.html</loc>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
</urlset>

21
frontend/sw-admin.js Normal file
View file

@ -0,0 +1,21 @@
const CACHE = 'rd-admin-v1';
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll([
'/', '/index.html', '/css/variables.css', '/css/reset.css', '/css/main.css',
'/js/app.js', '/js/api.js', '/js/socket.js', '/js/i18n.js',
'/js/components/toast.js'
])));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))));
self.clients.claim();
});
self.addEventListener('fetch', e => {
// Network first for API, cache first for static
if (e.request.url.includes('/api/') || e.request.url.includes('/socket.io/')) return;
e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));
});

View file

@ -0,0 +1,17 @@
#!/bin/bash
# Install ScreenTinker as a systemd service
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SERVICE_FILE="$SCRIPT_DIR/remotedisplay.service"
echo "Installing ScreenTinker service..."
sudo cp "$SERVICE_FILE" /etc/systemd/system/remotedisplay.service
sudo systemctl daemon-reload
sudo systemctl enable remotedisplay
sudo systemctl start remotedisplay
echo "Done! Service status:"
sudo systemctl status remotedisplay --no-pager
echo ""
echo "Commands:"
echo " sudo systemctl status remotedisplay"
echo " sudo systemctl restart remotedisplay"
echo " sudo journalctl -u remotedisplay -f"

View file

@ -0,0 +1,106 @@
#!/bin/bash
# ScreenTinker - Raspberry Pi Setup Script
# Run: curl -sSL https://screentinker.com/scripts/pi-setup.sh | bash
#
# This sets up a Raspberry Pi as a digital signage player:
# 1. Installs Chromium if needed
# 2. Creates a systemd service for kiosk mode
# 3. Auto-starts on boot
SERVER_URL="${1:-https://screentinker.com}"
PLAYER_URL="$SERVER_URL/player"
echo "=================================="
echo " ScreenTinker Pi Player Setup"
echo "=================================="
echo "Server: $SERVER_URL"
echo ""
# Install chromium if not present
if ! command -v chromium-browser &> /dev/null && ! command -v chromium &> /dev/null; then
echo "Installing Chromium..."
sudo apt-get update && sudo apt-get install -y chromium-browser unclutter
fi
CHROMIUM=$(command -v chromium-browser || command -v chromium)
# Disable screen blanking
if [ -f /etc/lightdm/lightdm.conf ]; then
sudo sed -i 's/#xserver-command=X/xserver-command=X -s 0 -dpms/' /etc/lightdm/lightdm.conf
fi
# Create autostart directory
mkdir -p ~/.config/autostart
# Create kiosk script
cat > ~/remotedisplay-kiosk.sh << EOF
#!/bin/bash
# Wait for network
sleep 5
# Disable screen saver and power management
xset s off
xset -dpms
xset s noblank
# Hide cursor
unclutter -idle 0.1 -root &
# Launch Chromium in kiosk mode
$CHROMIUM \\
--noerrandprompts \\
--disable-infobars \\
--disable-session-crashed-bubble \\
--kiosk \\
--incognito \\
--autoplay-policy=no-user-gesture-required \\
--disable-features=TranslateUI \\
--check-for-update-interval=31536000 \\
--disable-component-update \\
"$PLAYER_URL"
EOF
chmod +x ~/remotedisplay-kiosk.sh
# Create systemd service
sudo tee /etc/systemd/system/remotedisplay.service > /dev/null << EOF
[Unit]
Description=ScreenTinker Kiosk Player
After=graphical.target
Wants=graphical.target
[Service]
Type=simple
User=$USER
Environment=DISPLAY=:0
ExecStart=/bin/bash $HOME/remotedisplay-kiosk.sh
Restart=always
RestartSec=10
[Install]
WantedBy=graphical.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable remotedisplay.service
# Create desktop autostart entry (fallback)
cat > ~/.config/autostart/remotedisplay.desktop << EOF
[Desktop Entry]
Type=Application
Name=ScreenTinker
Exec=$HOME/remotedisplay-kiosk.sh
X-GNOME-Autostart-enabled=true
EOF
echo ""
echo "=================================="
echo " Setup Complete!"
echo "=================================="
echo ""
echo "The player will auto-start on next boot."
echo "To start now: ~/remotedisplay-kiosk.sh"
echo "To stop: sudo systemctl stop remotedisplay"
echo "Player URL: $PLAYER_URL"
echo ""
echo "Press Escape in the player to reset/reconfigure."
echo "Press F for fullscreen toggle."

View file

@ -0,0 +1,19 @@
[Unit]
Description=ScreenTinker Digital Signage Server
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=owner
WorkingDirectory=/home/owner/Downloads/remote_display/server
ExecStart=/home/owner/.nvm/versions/node/v20.20.1/bin/node server.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production
StandardOutput=journal
StandardError=journal
SyslogIdentifier=remotedisplay
[Install]
WantedBy=multi-user.target

44
scripts/reset-admin.js Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env node
/**
* Emergency admin access for self-hosted ScreenTinker.
* Run this on the server to get a temporary admin login URL.
*
* Usage: node scripts/reset-admin.js
*/
const path = require('path');
const config = require(path.join(__dirname, '..', 'server', 'config'));
const jwt = require(path.join(__dirname, '..', 'server', 'node_modules', 'jsonwebtoken'));
const crypto = require('crypto');
const nonce = crypto.randomBytes(8).toString('hex');
const token = jwt.sign(
{ id: 'recovery-' + nonce, email: 'admin@localhost', role: 'admin' },
config.jwtSecret,
{ expiresIn: '1h' }
);
const port = config.port || 3001;
console.log(`
ScreenTinker Admin Recovery
A temporary admin token has been generated.
Valid for 1 hour. Use it to log in and reset
your password or create a new admin account.
Token: ${token}
To use: Open your ScreenTinker instance, open browser
console (F12), and run:
localStorage.setItem('token', '${token}');
localStorage.setItem('user', '${JSON.stringify({ id: 'recovery-' + nonce, email: 'admin@localhost', name: 'Recovery Admin', role: 'admin', plan_id: 'enterprise' }).replace(/'/g, "\\'")}');
location.reload();
Or use the API directly:
curl -H "Authorization: Bearer ${token}" http://localhost:${port}/api/devices
`);

37
scripts/windows-setup.bat Normal file
View file

@ -0,0 +1,37 @@
@echo off
REM ScreenTinker - Windows Kiosk Setup
REM Run as Administrator
set SERVER_URL=https://your-server-url
set PLAYER_URL=%SERVER_URL%/player
echo ==================================
echo ScreenTinker Windows Player
echo ==================================
echo.
REM Create startup shortcut
set STARTUP=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup
set SHORTCUT=%STARTUP%\ScreenTinker.url
echo [InternetShortcut] > "%SHORTCUT%"
echo URL=%PLAYER_URL% >> "%SHORTCUT%"
REM Create a VBS launcher for kiosk mode (Chrome)
set LAUNCHER=%USERPROFILE%\ScreenTinker.vbs
echo Set WshShell = CreateObject("WScript.Shell") > "%LAUNCHER%"
echo WshShell.Run """C:\Program Files\Google\Chrome\Application\chrome.exe"" --kiosk --autoplay-policy=no-user-gesture-required ""%PLAYER_URL%""", 1, False >> "%LAUNCHER%"
REM Replace startup shortcut with VBS launcher
copy /Y "%LAUNCHER%" "%STARTUP%\ScreenTinker.vbs" >nul
echo.
echo Setup complete!
echo.
echo The player will auto-start on next login.
echo To start now, open: %PLAYER_URL%
echo Or run: %LAUNCHER%
echo.
echo Press any key to launch the player now...
pause >nul
start chrome --kiosk --autoplay-policy=no-user-gesture-required "%PLAYER_URL%"

42
server/config.js Normal file
View file

@ -0,0 +1,42 @@
const path = require('path');
module.exports = {
port: process.env.PORT || 3001,
httpsPort: process.env.HTTPS_PORT || 3443,
dbPath: path.join(__dirname, 'db', 'remote_display.db'),
uploadsDir: path.join(__dirname, 'uploads'),
contentDir: path.join(__dirname, 'uploads', 'content'),
screenshotsDir: path.join(__dirname, 'uploads', 'screenshots'),
frontendDir: path.join(__dirname, '..', 'frontend'),
heartbeatInterval: 10000, // Check every 10s
heartbeatTimeout: 45000, // Offline after 45s (3 missed 15s beats)
maxFileSize: 500 * 1024 * 1024, // 500MB
thumbnailWidth: 320,
screenshotQuality: 70,
// SSL: drop your Cloudflare Origin cert + key in certs/ folder
// or set env vars SSL_CERT and SSL_KEY to custom paths
sslCert: process.env.SSL_CERT || path.join(__dirname, 'certs', 'cert.pem'),
sslKey: process.env.SSL_KEY || path.join(__dirname, 'certs', 'key.pem'),
// Auth
jwtSecret: process.env.JWT_SECRET || (() => {
const secretFile = path.join(__dirname, 'certs', '.jwt_secret');
const fs = require('fs');
if (fs.existsSync(secretFile)) return fs.readFileSync(secretFile, 'utf8').trim();
const secret = require('crypto').randomBytes(64).toString('hex');
try { fs.mkdirSync(path.dirname(secretFile), { recursive: true }); fs.writeFileSync(secretFile, secret); } catch {}
return secret;
})(),
jwtExpiry: '7d',
// Google OAuth - set these in env or here
googleClientId: process.env.GOOGLE_CLIENT_ID || '',
// Microsoft OAuth - set these in env or here
microsoftClientId: process.env.MICROSOFT_CLIENT_ID || '',
microsoftTenantId: process.env.MICROSOFT_TENANT_ID || 'common',
// Stripe (optional - for paid subscriptions)
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
// Email alerts webhook URL (POST endpoint for sending emails)
emailWebhookUrl: process.env.EMAIL_WEBHOOK_URL || '',
// Self-hosted mode: if true, first user gets enterprise plan and no billing
selfHosted: process.env.SELF_HOSTED === 'true',
};

96
server/db/database.js Normal file
View file

@ -0,0 +1,96 @@
const Database = require('better-sqlite3');
const fs = require('fs');
const path = require('path');
const config = require('../config');
const dbDir = path.dirname(config.dbPath);
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
const db = new Database(config.dbPath);
// Enable WAL mode and foreign keys
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Run schema
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
// Migrations for existing databases
const migrations = [
'ALTER TABLE content ADD COLUMN remote_url TEXT',
'ALTER TABLE devices ADD COLUMN user_id TEXT REFERENCES users(id)',
'ALTER TABLE content ADD COLUMN user_id TEXT REFERENCES users(id)',
"ALTER TABLE users ADD COLUMN plan_id TEXT DEFAULT 'free'",
'ALTER TABLE users ADD COLUMN stripe_customer_id TEXT',
'ALTER TABLE users ADD COLUMN stripe_subscription_id TEXT',
"ALTER TABLE users ADD COLUMN subscription_status TEXT DEFAULT 'active'",
'ALTER TABLE users ADD COLUMN subscription_ends INTEGER',
// Layout & zone support on devices and assignments
'ALTER TABLE devices ADD COLUMN layout_id TEXT',
'ALTER TABLE devices ADD COLUMN timezone TEXT DEFAULT \'UTC\'',
'ALTER TABLE devices ADD COLUMN wall_id TEXT',
'ALTER TABLE devices ADD COLUMN team_id TEXT',
'ALTER TABLE assignments ADD COLUMN zone_id TEXT',
'ALTER TABLE assignments ADD COLUMN widget_id TEXT',
// Team support on content
'ALTER TABLE content ADD COLUMN team_id TEXT',
// Device notes
'ALTER TABLE devices ADD COLUMN notes TEXT',
// Email settings on users
"ALTER TABLE users ADD COLUMN email_alerts INTEGER DEFAULT 1",
// Content folders
'ALTER TABLE content ADD COLUMN folder TEXT',
// Device orientation and default content
"ALTER TABLE devices ADD COLUMN orientation TEXT DEFAULT 'landscape'",
'ALTER TABLE devices ADD COLUMN default_content_id TEXT',
// Audio control per assignment
"ALTER TABLE assignments ADD COLUMN muted INTEGER DEFAULT 0",
// Trial tracking
"ALTER TABLE users ADD COLUMN trial_started INTEGER",
"ALTER TABLE users ADD COLUMN trial_plan TEXT DEFAULT 'pro'",
// Stripe price IDs on plans
"ALTER TABLE plans ADD COLUMN stripe_price_monthly TEXT",
"ALTER TABLE plans ADD COLUMN stripe_price_yearly TEXT",
// Last login tracking
"ALTER TABLE users ADD COLUMN last_login INTEGER",
];
for (const sql of migrations) {
try { db.exec(sql); } catch (e) { /* already exists */ }
}
// Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000)
function pruneTelemetry(deviceId) {
db.prepare(`
DELETE FROM device_telemetry
WHERE device_id = ? AND id NOT IN (
SELECT id FROM device_telemetry
WHERE device_id = ?
ORDER BY reported_at DESC LIMIT 6000
)
`).run(deviceId, deviceId);
}
// Prune old screenshots (keep only latest per device)
function pruneScreenshots(deviceId) {
const old = db.prepare(`
SELECT filepath FROM screenshots
WHERE device_id = ? AND id NOT IN (
SELECT id FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1
)
`).all(deviceId, deviceId);
for (const row of old) {
const fullPath = path.join(config.screenshotsDir, row.filepath);
if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath);
}
db.prepare(`
DELETE FROM screenshots
WHERE device_id = ? AND id NOT IN (
SELECT id FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1
)
`).run(deviceId, deviceId);
}
module.exports = { db, pruneTelemetry, pruneScreenshots };

385
server/db/schema.sql Normal file
View file

@ -0,0 +1,385 @@
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
display_name TEXT NOT NULL,
max_devices INTEGER NOT NULL DEFAULT 2,
max_storage_mb INTEGER NOT NULL DEFAULT 500,
remote_control INTEGER NOT NULL DEFAULT 0,
remote_url INTEGER NOT NULL DEFAULT 0,
priority_support INTEGER NOT NULL DEFAULT 0,
price_monthly REAL NOT NULL DEFAULT 0,
price_yearly REAL NOT NULL DEFAULT 0,
stripe_monthly_id TEXT,
stripe_yearly_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1
);
-- Default plans
INSERT OR IGNORE INTO plans (id, name, display_name, max_devices, max_storage_mb, remote_control, remote_url, priority_support, price_monthly, price_yearly, sort_order)
VALUES
('free', 'free', 'Free', 2, 500, 0, 0, 0, 0, 0, 0),
('starter', 'starter', 'Starter', 8, 2048, 1, 0, 0, 9.99, 99, 1),
('pro', 'pro', 'Pro', 25, 10240, 1, 1, 0, 24.99, 249, 2),
('enterprise', 'enterprise', 'Enterprise', -1, -1, 1, 1, 1, 49.99, 499, 3);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL DEFAULT '',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'local',
provider_id TEXT,
avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user',
plan_id TEXT DEFAULT 'free' REFERENCES plans(id),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
subscription_status TEXT DEFAULT 'active',
subscription_ends INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
name TEXT NOT NULL DEFAULT 'Unnamed Display',
pairing_code TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'offline',
last_heartbeat INTEGER,
ip_address TEXT,
android_version TEXT,
app_version TEXT,
screen_width INTEGER,
screen_height INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS device_telemetry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
battery_level INTEGER,
battery_charging INTEGER NOT NULL DEFAULT 0,
storage_free_mb INTEGER,
storage_total_mb INTEGER,
ram_free_mb INTEGER,
ram_total_mb INTEGER,
cpu_usage REAL,
wifi_ssid TEXT,
wifi_rssi INTEGER,
uptime_seconds INTEGER,
reported_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_telemetry_device ON device_telemetry(device_id, reported_at DESC);
CREATE TABLE IF NOT EXISTS content (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
filename TEXT NOT NULL,
filepath TEXT NOT NULL DEFAULT '',
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
duration_sec REAL,
thumbnail_path TEXT,
width INTEGER,
height INTEGER,
remote_url TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
content_id TEXT REFERENCES content(id) ON DELETE CASCADE,
widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE,
zone_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
duration_sec INTEGER NOT NULL DEFAULT 10,
schedule_start TEXT,
schedule_end TEXT,
schedule_days TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS screenshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
filepath TEXT NOT NULL,
captured_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_screenshots_device ON screenshots(device_id, captured_at DESC);
-- ===================== LAYOUTS & ZONES =====================
CREATE TABLE IF NOT EXISTS layouts (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
team_id TEXT,
name TEXT NOT NULL,
width INTEGER NOT NULL DEFAULT 1920,
height INTEGER NOT NULL DEFAULT 1080,
is_template INTEGER NOT NULL DEFAULT 0,
template_category TEXT,
thumbnail_data TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS layout_zones (
id TEXT PRIMARY KEY,
layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Zone',
x_percent REAL NOT NULL DEFAULT 0,
y_percent REAL NOT NULL DEFAULT 0,
width_percent REAL NOT NULL DEFAULT 100,
height_percent REAL NOT NULL DEFAULT 100,
z_index INTEGER NOT NULL DEFAULT 0,
zone_type TEXT NOT NULL DEFAULT 'content',
fit_mode TEXT NOT NULL DEFAULT 'cover',
background_color TEXT DEFAULT '#000000',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_zones_layout ON layout_zones(layout_id);
-- Seed templates
INSERT OR IGNORE INTO layouts (id, user_id, name, is_template, template_category) VALUES
('tpl-fullscreen', NULL, 'Fullscreen', 1, 'basic'),
('tpl-split-h', NULL, 'Split Horizontal', 1, 'split'),
('tpl-split-v', NULL, 'Split Vertical', 1, 'split'),
('tpl-l-bar', NULL, 'L-Bar with Ticker', 1, 'news'),
('tpl-pip', NULL, 'Picture in Picture', 1, 'overlay'),
('tpl-thirds', NULL, 'Three Column', 1, 'grid'),
('tpl-quad', NULL, 'Four Quadrants', 1, 'grid');
INSERT OR IGNORE INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, sort_order) VALUES
('z-fs-1', 'tpl-fullscreen', 'Main', 0, 0, 100, 100, 0, 0),
('z-sh-1', 'tpl-split-h', 'Left', 0, 0, 50, 100, 0, 0),
('z-sh-2', 'tpl-split-h', 'Right', 50, 0, 50, 100, 0, 1),
('z-sv-1', 'tpl-split-v', 'Top', 0, 0, 100, 50, 0, 0),
('z-sv-2', 'tpl-split-v', 'Bottom', 0, 50, 100, 50, 0, 1),
('z-lb-1', 'tpl-l-bar', 'Main Content', 0, 0, 75, 85, 0, 0),
('z-lb-2', 'tpl-l-bar', 'Side Panel', 75, 0, 25, 100, 0, 1),
('z-lb-3', 'tpl-l-bar', 'Bottom Ticker', 0, 85, 75, 15, 1, 2),
('z-pip-1', 'tpl-pip', 'Background', 0, 0, 100, 100, 0, 0),
('z-pip-2', 'tpl-pip', 'PiP Window', 65, 5, 30, 30, 1, 1),
('z-th-1', 'tpl-thirds', 'Left', 0, 0, 33.33, 100, 0, 0),
('z-th-2', 'tpl-thirds', 'Center', 33.33, 0, 33.34, 100, 0, 1),
('z-th-3', 'tpl-thirds', 'Right', 66.67, 0, 33.33, 100, 0, 2),
('z-q-1', 'tpl-quad', 'Top Left', 0, 0, 50, 50, 0, 0),
('z-q-2', 'tpl-quad', 'Top Right', 50, 0, 50, 50, 0, 1),
('z-q-3', 'tpl-quad', 'Bottom Left', 0, 50, 50, 50, 0, 2),
('z-q-4', 'tpl-quad', 'Bottom Right', 50, 50, 50, 50, 0, 3);
-- ===================== WIDGETS =====================
CREATE TABLE IF NOT EXISTS widgets (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
team_id TEXT,
widget_type TEXT NOT NULL,
name TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- ===================== SCHEDULES =====================
CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
zone_id TEXT REFERENCES layout_zones(id) ON DELETE CASCADE,
content_id TEXT REFERENCES content(id) ON DELETE CASCADE,
widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE,
layout_id TEXT REFERENCES layouts(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT '',
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC',
recurrence TEXT,
recurrence_end TEXT,
priority INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
color TEXT DEFAULT '#3B82F6',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_schedules_device ON schedules(device_id, enabled);
-- ===================== VIDEO WALLS =====================
CREATE TABLE IF NOT EXISTS video_walls (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
team_id TEXT,
name TEXT NOT NULL,
grid_cols INTEGER NOT NULL DEFAULT 2,
grid_rows INTEGER NOT NULL DEFAULT 2,
bezel_h_mm REAL NOT NULL DEFAULT 0,
bezel_v_mm REAL NOT NULL DEFAULT 0,
screen_w_mm REAL NOT NULL DEFAULT 400,
screen_h_mm REAL NOT NULL DEFAULT 225,
sync_mode TEXT NOT NULL DEFAULT 'leader',
leader_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL,
content_id TEXT REFERENCES content(id) ON DELETE SET NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS video_wall_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wall_id TEXT NOT NULL REFERENCES video_walls(id) ON DELETE CASCADE,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
grid_col INTEGER NOT NULL,
grid_row INTEGER NOT NULL,
rotation INTEGER NOT NULL DEFAULT 0,
UNIQUE(wall_id, device_id),
UNIQUE(wall_id, grid_col, grid_row)
);
-- ===================== TEAMS =====================
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_id TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'viewer',
invited_by TEXT REFERENCES users(id),
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(team_id, user_id)
);
CREATE TABLE IF NOT EXISTS team_invites (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
invited_by TEXT NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- ===================== PROOF-OF-PLAY =====================
CREATE TABLE IF NOT EXISTS play_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
content_id TEXT REFERENCES content(id) ON DELETE SET NULL,
widget_id TEXT REFERENCES widgets(id) ON DELETE SET NULL,
zone_id TEXT,
content_name TEXT NOT NULL DEFAULT '',
started_at INTEGER NOT NULL,
ended_at INTEGER,
duration_sec INTEGER,
completed INTEGER NOT NULL DEFAULT 0,
trigger_type TEXT DEFAULT 'playlist',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_play_logs_device ON play_logs(device_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_play_logs_content ON play_logs(content_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_play_logs_time ON play_logs(started_at, ended_at);
-- ===================== DEVICE GROUPS =====================
CREATE TABLE IF NOT EXISTS device_groups (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
color TEXT DEFAULT '#3B82F6',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS device_group_members (
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES device_groups(id) ON DELETE CASCADE,
PRIMARY KEY (device_id, group_id)
);
-- ===================== ACTIVITY LOG =====================
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT REFERENCES users(id),
device_id TEXT,
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_activity_log_time ON activity_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_log_user ON activity_log(user_id, created_at DESC);
-- ===================== EMAIL ALERTS =====================
-- ===================== WHITE LABEL =====================
CREATE TABLE IF NOT EXISTS white_labels (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
brand_name TEXT NOT NULL DEFAULT 'ScreenTinker',
logo_url TEXT,
favicon_url TEXT,
primary_color TEXT DEFAULT '#3B82F6',
secondary_color TEXT DEFAULT '#1E293B',
bg_color TEXT DEFAULT '#111827',
custom_domain TEXT,
custom_css TEXT,
hide_branding INTEGER DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- ===================== KIOSK PAGES =====================
CREATE TABLE IF NOT EXISTS kiosk_pages (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- ===================== DEVICE FINGERPRINTS =====================
CREATE TABLE IF NOT EXISTS device_fingerprints (
fingerprint TEXT NOT NULL,
device_id TEXT REFERENCES devices(id) ON DELETE SET NULL,
user_id TEXT REFERENCES users(id),
first_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')),
last_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')),
PRIMARY KEY (fingerprint)
);
CREATE TABLE IF NOT EXISTS alert_configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
alert_type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
config TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS device_status_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
status TEXT NOT NULL,
timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

67
server/middleware/auth.js Normal file
View file

@ -0,0 +1,67 @@
const jwt = require('jsonwebtoken');
const config = require('../config');
const { db } = require('../db/database');
function generateToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role },
config.jwtSecret,
{ expiresIn: config.jwtExpiry }
);
}
function verifyToken(token) {
return jwt.verify(token, config.jwtSecret);
}
// Express middleware - requires valid JWT
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const token = authHeader.split(' ')[1];
const decoded = verifyToken(token);
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id);
if (!user) return res.status(401).json({ error: 'User not found' });
req.user = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Optional auth - sets req.user if token present, continues either way
function optionalAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
try {
const token = authHeader.split(' ')[1];
const decoded = verifyToken(token);
req.user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id);
} catch (err) {
// Token invalid, continue without user
}
}
next();
}
// Require admin role (admin or superadmin)
function requireAdmin(req, res, next) {
if (!req.user || !['admin', 'superadmin'].includes(req.user.role)) {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
// Require superadmin role (platform owner only)
function requireSuperAdmin(req, res, next) {
if (!req.user || req.user.role !== 'superadmin') {
return res.status(403).json({ error: 'Platform admin access required' });
}
next();
}
module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin };

View file

@ -0,0 +1,25 @@
// Simple XSS sanitizer for user input strings
function sanitizeString(str) {
if (typeof str !== 'string') return str;
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// Middleware: sanitize common body fields
function sanitizeBody(req, res, next) {
if (req.body) {
const fieldsToSanitize = ['name', 'title', 'filename'];
for (const field of fieldsToSanitize) {
if (typeof req.body[field] === 'string') {
req.body[field] = sanitizeString(req.body[field]);
}
}
}
next();
}
module.exports = { sanitizeString, sanitizeBody };

View file

@ -0,0 +1,145 @@
const { db } = require('../db/database');
const config = require('../config');
const TRIAL_DAYS = 14;
function getUserPlan(userId) {
const user = db.prepare(`
SELECT u.*, p.name as plan_name, p.display_name as plan_display_name,
p.max_devices, p.max_storage_mb, p.remote_control, p.remote_url,
p.priority_support, p.price_monthly, p.price_yearly
FROM users u
JOIN plans p ON u.plan_id = p.id
WHERE u.id = ?
`).get(userId);
// Check if trial has expired
if (user && user.trial_started) {
const trialEnd = user.trial_started + (TRIAL_DAYS * 86400);
const now = Math.floor(Date.now() / 1000);
user.trial_active = now < trialEnd;
user.trial_days_left = Math.max(0, Math.ceil((trialEnd - now) / 86400));
user.trial_end = trialEnd;
// Auto-downgrade if trial expired and no paid subscription
if (!user.trial_active && user.subscription_status !== 'active' && user.plan_name !== 'free') {
db.prepare("UPDATE users SET plan_id = 'free', trial_started = NULL WHERE id = ?").run(userId);
// Re-fetch with free plan
return getUserPlan(userId);
}
} else {
user.trial_active = false;
user.trial_days_left = 0;
}
return user;
}
function getUserDeviceCount(userId) {
return db.prepare('SELECT COUNT(*) as count FROM devices WHERE user_id = ?').get(userId).count;
}
function getUserStorageMB(userId) {
const result = db.prepare('SELECT COALESCE(SUM(file_size), 0) as total FROM content WHERE user_id = ?').get(userId);
return Math.ceil(result.total / (1024 * 1024));
}
// Check if user can add more devices
function checkDeviceLimit(req, res, next) {
const plan = getUserPlan(req.user.id);
if (!plan) return res.status(403).json({ error: 'No plan found' });
// -1 means unlimited
if (plan.max_devices === -1) return next();
const deviceCount = getUserDeviceCount(req.user.id);
if (deviceCount >= plan.max_devices) {
return res.status(403).json({
error: `Device limit reached (${plan.max_devices} on ${plan.plan_display_name} plan). Upgrade to add more.`,
code: 'DEVICE_LIMIT',
current: deviceCount,
limit: plan.max_devices,
plan: plan.plan_name
});
}
next();
}
// Check if user can upload more content
function checkStorageLimit(req, res, next) {
const plan = getUserPlan(req.user.id);
if (!plan) return res.status(403).json({ error: 'No plan found' });
// -1 means unlimited
if (plan.max_storage_mb === -1) return next();
const usedMB = getUserStorageMB(req.user.id);
if (usedMB >= plan.max_storage_mb) {
return res.status(403).json({
error: `Storage limit reached (${plan.max_storage_mb}MB on ${plan.plan_display_name} plan). Upgrade for more.`,
code: 'STORAGE_LIMIT',
current_mb: usedMB,
limit_mb: plan.max_storage_mb,
plan: plan.plan_name
});
}
next();
}
// Check if user has remote control access
function checkRemoteControl(req, res, next) {
const plan = getUserPlan(req.user.id);
if (!plan || !plan.remote_control) {
return res.status(403).json({
error: 'Remote control requires Starter plan or above.',
code: 'FEATURE_LOCKED',
plan: plan?.plan_name
});
}
next();
}
// Check remote URL feature access
function checkRemoteUrl(req, res, next) {
const plan = getUserPlan(req.user.id);
if (!plan || !plan.remote_url) {
return res.status(403).json({
error: 'Remote URL content requires Pro plan or above.',
code: 'FEATURE_LOCKED',
plan: plan?.plan_name
});
}
next();
}
// Check subscription is active (not expired)
function checkActiveSubscription(req, res, next) {
const plan = getUserPlan(req.user.id);
if (!plan) return res.status(403).json({ error: 'No plan found' });
// Free plan is always active
if (plan.plan_name === 'free') return next();
// Self-hosted mode doesn't check expiry
if (config.selfHosted) return next();
// Check if subscription has expired
if (plan.subscription_status !== 'active' && plan.subscription_ends && plan.subscription_ends < Math.floor(Date.now() / 1000)) {
return res.status(403).json({
error: 'Subscription expired. Please renew to continue.',
code: 'SUBSCRIPTION_EXPIRED'
});
}
next();
}
module.exports = {
getUserPlan,
getUserDeviceCount,
getUserStorageMB,
checkDeviceLimit,
checkStorageLimit,
checkRemoteControl,
checkRemoteUrl,
checkActiveSubscription
};

View file

@ -0,0 +1,35 @@
const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const config = require('../config');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, config.contentDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
}
});
const fileFilter = (req, file, cb) => {
const allowedTypes = [
'video/mp4', 'video/webm', 'video/avi', 'video/mkv', 'video/mov',
'video/x-msvideo', 'video/quicktime', 'video/x-matroska',
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'
];
if (allowedTypes.includes(file.mimetype) || file.mimetype.startsWith('video/') || file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only video and image files are allowed'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: { fileSize: config.maxFileSize }
});
module.exports = upload;

3682
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
server/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "remote-display-server",
"version": "1.0.0",
"description": "ScreenTinker - Digital Signage Management Server",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"archiver": "^7.0.1",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^9.4.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^8.3.1",
"google-auth-library": "^10.6.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"sharp": "^0.33.2",
"socket.io": "^4.7.2",
"stripe": "^20.4.1",
"unzipper": "^0.12.3",
"uuid": "^9.0.0"
}
}

788
server/player/index.html Normal file
View file

@ -0,0 +1,788 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>ScreenTinker Player</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: -apple-system, sans-serif; }
/* Setup Screen */
#setupScreen {
position: fixed; inset: 0; background: #111827; display: flex; flex-direction: column;
align-items: center; justify-content: center; z-index: 1000; color: #f1f5f9;
}
#setupScreen h1 { font-size: 36px; color: #3b82f6; margin-bottom: 8px; }
#setupScreen .subtitle { color: #94a3b8; font-size: 16px; margin-bottom: 48px; }
#setupScreen .form { width: 400px; max-width: 90vw; }
#setupScreen label { display: block; font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
#setupScreen input { width: 100%; padding: 12px; background: #0f172a; border: 1px solid #334155;
border-radius: 8px; color: #f1f5f9; font-size: 16px; margin-bottom: 24px; outline: none; }
#setupScreen input:focus { border-color: #3b82f6; }
#setupScreen button { width: 100%; padding: 12px; background: #3b82f6; color: white;
border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; }
#setupScreen button:hover { background: #2563eb; }
#setupScreen button:disabled { opacity: 0.5; cursor: not-allowed; }
.pairing-code { font-size: 72px; font-weight: 700; color: #3b82f6; font-family: monospace;
letter-spacing: 12px; margin: 24px 0; }
.pairing-hint { color: #64748b; font-size: 14px; }
.status-msg { color: #94a3b8; font-size: 14px; margin-top: 16px; }
.spinner { width: 40px; height: 40px; border: 3px solid #334155; border-top-color: #3b82f6;
border-radius: 50%; animation: spin 1s linear infinite; margin: 24px auto; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Player */
#playerContainer { position: fixed; inset: 0; background: #000; }
.zone { position: absolute; overflow: hidden; }
.zone video { width: 100%; height: 100%; object-fit: cover; }
.zone img { width: 100%; height: 100%; object-fit: cover; }
.zone iframe { width: 100%; height: 100%; border: none; }
/* Status overlay */
#statusOverlay {
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: #94a3b8; z-index: 500;
}
#statusOverlay h2 { color: #3b82f6; font-size: 28px; margin-bottom: 8px; }
#statusOverlay p { font-size: 16px; }
</style>
</head>
<body>
<!-- Setup Screen -->
<div id="setupScreen">
<h1>ScreenTinker</h1>
<div class="subtitle">Web Player</div>
<div class="form" id="urlForm">
<label>Server URL</label>
<input type="url" id="serverUrl" placeholder="https://sign.yourdomain.com" autofocus>
<button id="connectBtn">Connect</button>
</div>
<div id="pairingSection" style="display:none;text-align:center">
<p>Pairing Code</p>
<div class="pairing-code" id="pairingCode">------</div>
<p class="pairing-hint">Enter this code in the dashboard to pair this display</p>
</div>
<div class="spinner" id="setupSpinner" style="display:none"></div>
<div class="status-msg" id="setupStatus"></div>
</div>
<!-- Player Container -->
<div id="playerContainer" style="display:none"></div>
<!-- Status Overlay -->
<div id="statusOverlay" style="display:none">
<div class="spinner"></div>
<h2>ScreenTinker</h2>
<p id="statusText">Connecting...</p>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
// ==================== Config ====================
const STORAGE_KEY = 'rd_web_player';
const HEARTBEAT_INTERVAL = 15000;
const PLAYLIST_REFRESH_INTERVAL = 60000;
function getConfig() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); } catch { return {}; }
}
function saveConfig(cfg) { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); }
// ==================== State ====================
let socket = null;
let config = getConfig();
let playlist = [];
let currentIndex = -1;
let isPlaying = false;
let heartbeatTimer = null;
let refreshTimer = null;
let remoteStreaming = false;
let streamTimer = null;
let layout = null;
let zones = {};
let userHasInteracted = false;
// Track user interaction for autoplay policy
['click', 'touchstart', 'keydown'].forEach(evt => {
document.addEventListener(evt, () => {
userHasInteracted = true;
// Try to unmute any playing video
const video = document.querySelector('#playerContainer video');
if (video && video.muted) {
video.muted = false;
video.play().catch(() => {});
console.log('Unmuted video after user interaction');
}
}, { once: false });
});
// ==================== Browser Fingerprint ====================
function generateBrowserFingerprint() {
const components = [
navigator.userAgent,
navigator.language,
screen.width + 'x' + screen.height,
screen.colorDepth,
new Date().getTimezoneOffset(),
navigator.hardwareConcurrency || 0,
navigator.platform,
// Canvas fingerprint
(() => {
try {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillText('ScreenTinker fingerprint', 2, 2);
return c.toDataURL().slice(-50);
} catch { return ''; }
})(),
];
// Simple hash
const str = components.join('|');
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return 'web-' + Math.abs(hash).toString(36) + '-' + str.length.toString(36);
}
// ==================== Boot ====================
if (config.serverUrl && config.deviceId && config.paired) {
// Show tap-to-start overlay to unlock audio on auto-reconnect
const tapOverlay = document.createElement('div');
tapOverlay.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
tapOverlay.innerHTML = `
<h1 style="color:#3b82f6;font-size:36px;font-family:sans-serif;margin-bottom:12px">ScreenTinker</h1>
<p style="color:#94a3b8;font-size:18px;font-family:sans-serif">Tap anywhere to start</p>
<p style="color:#64748b;font-size:13px;font-family:sans-serif;margin-top:24px">Audio requires user interaction</p>
`;
tapOverlay.onclick = () => {
unlockAudio();
tapOverlay.remove();
showStatus('Connecting...');
connect(config.serverUrl);
};
document.body.appendChild(tapOverlay);
// Auto-dismiss after 5 seconds if no interaction (plays muted)
setTimeout(() => {
if (tapOverlay.parentNode) {
tapOverlay.remove();
showStatus('Connecting (audio muted)...');
connect(config.serverUrl);
}
}, 5000);
}
// ==================== Setup UI ====================
const savedUrl = config.serverUrl || '';
document.getElementById('serverUrl').value = savedUrl;
// Unlock audio on any user interaction
function unlockAudio() {
userHasInteracted = true;
// Create and resume AudioContext (unlocks audio for the session)
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
ctx.resume().then(() => { console.log('AudioContext unlocked'); });
// Play a silent buffer to fully unlock
const buf = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(0);
} catch(e) { console.warn('Audio unlock failed:', e); }
// Unmute any playing video
document.querySelectorAll('video').forEach(v => { v.muted = false; });
}
document.getElementById('connectBtn').onclick = () => {
unlockAudio();
const url = document.getElementById('serverUrl').value.trim().replace(/\/$/, '');
if (!url) return;
config.serverUrl = url;
saveConfig(config);
document.getElementById('connectBtn').disabled = true;
document.getElementById('setupSpinner').style.display = 'block';
document.getElementById('setupStatus').textContent = 'Connecting...';
connect(url);
};
// ==================== Socket Connection ====================
function connect(serverUrl) {
if (socket) { socket.disconnect(); socket = null; }
socket = io(serverUrl + '/device', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000,
timeout: 20000,
});
socket.on('connect', () => {
console.log('Connected');
register();
});
socket.on('disconnect', () => {
console.log('Disconnected');
stopHeartbeat();
});
socket.on('connect_error', (err) => {
document.getElementById('setupStatus').textContent = 'Connection failed: ' + err.message;
document.getElementById('setupSpinner').style.display = 'none';
document.getElementById('connectBtn').disabled = false;
});
socket.on('device:registered', (data) => {
config.deviceId = data.device_id;
saveConfig(config);
console.log('Registered:', data.device_id);
if (!config.paired) {
// Show pairing code
document.getElementById('urlForm').style.display = 'none';
document.getElementById('setupSpinner').style.display = 'none';
document.getElementById('pairingSection').style.display = 'block';
document.getElementById('pairingCode').textContent = config.pairingCode || '------';
document.getElementById('setupStatus').textContent = '';
}
startHeartbeat();
startPlaylistRefresh();
});
socket.on('device:paired', (data) => {
config.paired = true;
config.deviceName = data.name;
saveConfig(config);
console.log('Paired as:', data.name);
document.getElementById('setupScreen').style.display = 'none';
showStatus('Waiting for content...');
});
socket.on('device:playlist-update', (data) => {
console.log('Playlist update:', data.assignments?.length, 'items');
handlePlaylistUpdate(data);
});
socket.on('device:content-delete', (data) => {
playlist = playlist.filter(p => p.content_id !== data.content_id);
if (playlist.length === 0) showStatus('Waiting for content...');
});
socket.on('device:screenshot-request', () => { console.log('Screenshot requested'); captureAndSend(); });
socket.on('device:remote-start', () => { console.log('Remote start received'); remoteStreaming = true; startStreaming(); });
socket.on('device:remote-stop', () => { console.log('Remote stop received'); remoteStreaming = false; stopStreaming(); });
socket.on('device:remote-touch', (data) => {
// Simulate click at normalized coordinates within the player
const container = document.getElementById('playerContainer');
if (!container) return;
const x = data.x * container.offsetWidth;
const y = data.y * container.offsetHeight;
const el = document.elementFromPoint(x, y);
if (el) el.click();
console.log('Touch:', data.x, data.y, '-> element:', el?.tagName);
});
socket.on('device:remote-key', (data) => {
console.log('Key:', data.keycode);
const video = document.querySelector('#playerContainer video');
switch (data.keycode) {
case 'KEYCODE_DPAD_RIGHT':
// Skip to next content
nextItem();
break;
case 'KEYCODE_DPAD_LEFT':
// Go to previous content
currentIndex = (currentIndex - 2 + playlist.length) % playlist.length;
nextItem();
break;
case 'KEYCODE_DPAD_CENTER':
case 'KEYCODE_ENTER':
// Toggle play/pause
if (video) { video.paused ? video.play() : video.pause(); }
break;
case 'KEYCODE_VOLUME_UP':
if (video) { video.volume = Math.min(1, video.volume + 0.1); video.muted = false; }
break;
case 'KEYCODE_VOLUME_DOWN':
if (video) { video.volume = Math.max(0, video.volume - 0.1); }
break;
case 'KEYCODE_MENU':
// Toggle mute
if (video) { video.muted = !video.muted; }
break;
case 'KEYCODE_HOME':
// Go back to first item
currentIndex = -1;
nextItem();
break;
case 'KEYCODE_BACK':
// Show/hide status overlay with device info
const overlay = document.getElementById('infoOverlay');
if (overlay) { overlay.style.display = overlay.style.display === 'none' ? 'flex' : 'none'; }
break;
case 'KEYCODE_POWER':
// Toggle screen (show black overlay)
toggleScreenOff();
break;
}
});
socket.on('device:command', (data) => {
console.log('Command:', data.type);
if (data.type === 'refresh') location.reload();
if (data.type === 'launch') { document.getElementById('screenOffOverlay')?.remove(); }
if (data.type === 'screen_off') toggleScreenOff();
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); }
});
}
function register() {
const data = {};
if (config.deviceId && config.paired) {
data.device_id = config.deviceId;
} else {
const code = String(Math.floor(100000 + Math.random() * 900000));
config.pairingCode = code;
saveConfig(config);
data.pairing_code = code;
}
data.device_info = {
android_version: 'Web/' + navigator.userAgent.split(' ').pop(),
app_version: '1.1.0-web',
screen_width: screen.width,
screen_height: screen.height,
};
// Browser fingerprint (survives localStorage clear)
data.fingerprint = generateBrowserFingerprint();
socket.emit('device:register', data);
}
// ==================== Heartbeat ====================
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (!socket?.connected || !config.deviceId) return;
socket.emit('device:heartbeat', {
device_id: config.deviceId,
telemetry: {
battery_level: null,
battery_charging: false,
storage_free_mb: null,
storage_total_mb: null,
ram_free_mb: null,
ram_total_mb: null,
cpu_usage: null,
wifi_ssid: 'Web Player',
wifi_rssi: null,
uptime_seconds: Math.floor(performance.now() / 1000),
}
});
}, HEARTBEAT_INTERVAL);
}
function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
function startPlaylistRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
if (socket?.connected && config.deviceId && config.paired) {
socket.emit('device:register', { device_id: config.deviceId, device_info: {} });
}
}, PLAYLIST_REFRESH_INTERVAL);
}
// ==================== Playlist ====================
function handlePlaylistUpdate(data) {
// Check if device is suspended (trial expired / over limit)
if (data.suspended) {
isPlaying = false;
playlist = [];
document.getElementById('playerContainer').style.display = 'none';
const overlay = document.getElementById('statusOverlay');
overlay.style.display = 'flex';
overlay.innerHTML = `
<div style="text-align:center;max-width:500px">
<div style="font-size:64px;margin-bottom:16px">&#9888;</div>
<h2 style="color:#f59e0b;margin-bottom:8px">${data.message || 'Account Suspended'}</h2>
<p style="color:#94a3b8;font-size:16px;margin-bottom:24px">${data.detail || 'Please upgrade your plan.'}</p>
<p style="color:#64748b;font-size:13px">Visit your dashboard to manage your subscription</p>
</div>
`;
return;
}
const newItems = data.assignments || [];
const newIds = newItems.map(a => a.content_id).join(',');
const oldIds = playlist.map(a => a.content_id).join(',');
// Apply orientation
if (data.orientation) {
const rotations = { 'landscape': '0deg', 'portrait': '90deg', 'landscape-flipped': '180deg', 'portrait-flipped': '270deg' };
document.getElementById('playerContainer').style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
if (data.orientation.includes('portrait')) {
document.getElementById('playerContainer').style.transformOrigin = 'center center';
document.getElementById('playerContainer').style.width = '100vh';
document.getElementById('playerContainer').style.height = '100vw';
}
}
layout = data.layout || null;
if (newIds === oldIds && playlist.length > 0) {
console.log('Playlist unchanged');
return;
}
playlist = newItems;
if (playlist.length === 0) {
showStatus('Waiting for content...');
isPlaying = false;
return;
}
document.getElementById('setupScreen').style.display = 'none';
if (!isPlaying) {
currentIndex = 0;
isPlaying = true;
playCurrentItem();
} else {
// Check if current item still exists
const curId = playlist[currentIndex]?.content_id;
if (!curId) { currentIndex = 0; playCurrentItem(); }
}
}
function playCurrentItem() {
if (currentIndex < 0 || currentIndex >= playlist.length) {
currentIndex = 0;
if (playlist.length === 0) { showStatus('Waiting for content...'); return; }
}
hideStatus();
const item = playlist[currentIndex];
console.log('Playing:', item.filename, `(${currentIndex + 1}/${playlist.length})`);
// Send play event
if (socket?.connected) {
socket.emit('device:play-event', {
device_id: config.deviceId,
event: 'play_start',
content_id: item.content_id,
content_name: item.filename,
});
}
renderContent(item);
}
function nextItem() {
// Send play_end for current
if (playlist[currentIndex] && socket?.connected) {
socket.emit('device:play-event', {
device_id: config.deviceId,
event: 'play_end',
content_id: playlist[currentIndex].content_id,
content_name: playlist[currentIndex].filename,
completed: true,
});
}
currentIndex = (currentIndex + 1) % playlist.length;
playCurrentItem();
}
// ==================== Content Rendering ====================
function renderContent(item) {
const container = document.getElementById('playerContainer');
container.style.display = 'block';
container.innerHTML = '';
const isYoutube = item.mime_type === 'video/youtube';
const isVideo = !isYoutube && item.mime_type?.startsWith('video/');
const isImage = item.mime_type?.startsWith('image/');
const remoteUrl = item.remote_url;
const serverUrl = config.serverUrl;
const src = remoteUrl || `${serverUrl}/uploads/content/${item.filepath}`;
if (layout && layout.zones && layout.zones.length > 1) {
renderZones(container, item);
} else {
// Fullscreen
if (isYoutube) {
const iframe = document.createElement('iframe');
iframe.src = src;
iframe.allow = 'autoplay; encrypted-media';
iframe.allowFullscreen = true;
iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000';
container.appendChild(iframe);
// YouTube videos loop via playlist param — advance after duration or loop indefinitely
if (playlist.length > 1 && item.duration_sec) {
setTimeout(nextItem, (item.duration_sec || 30) * 1000);
}
} else if (isVideo) {
const video = document.createElement('video');
video.src = src;
video.autoplay = true;
video.muted = !userHasInteracted; // Unmuted if user has interacted
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000';
video.onended = () => nextItem();
video.onerror = (e) => { console.error('Video error:', src, e); setTimeout(nextItem, 3000); };
video.onloadeddata = () => {
console.log('Video loaded:', item.filename, 'muted:', video.muted);
};
container.appendChild(video);
// Try playing unmuted, fall back to muted
video.play().catch(() => { video.muted = true; video.play().catch(() => {}); });
// Fallback: force play if not started after 2s
setTimeout(() => { if (video.paused) { video.muted = true; video.play().catch(() => {}); } }, 2000);
} else if (isImage) {
const img = document.createElement('img');
img.src = src;
img.style.cssText = 'width:100%;height:100%;object-fit:contain';
img.onerror = () => { console.error('Image error'); setTimeout(nextItem, 3000); };
container.appendChild(img);
// Auto advance for images
setTimeout(nextItem, (item.duration_sec || 10) * 1000);
}
}
}
function renderZones(container, defaultItem) {
// Multi-zone layout
layout.zones.forEach(zone => {
const div = document.createElement('div');
div.className = 'zone';
div.style.cssText = `left:${zone.x_percent}%;top:${zone.y_percent}%;width:${zone.width_percent}%;height:${zone.height_percent}%;z-index:${zone.z_index || 0}`;
// Find assignment for this zone
const assignment = playlist.find(a => a.zone_id === zone.id) || defaultItem;
if (!assignment) return;
const isVideo = assignment.mime_type?.startsWith('video/');
const src = assignment.remote_url || `${config.serverUrl}/uploads/content/${assignment.filepath}`;
const isYoutubeZone = assignment.mime_type === 'video/youtube';
if (zone.zone_type === 'widget' && assignment.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${config.serverUrl}/api/widgets/${assignment.widget_id}/render`;
div.appendChild(iframe);
} else if (isYoutubeZone) {
const iframe = document.createElement('iframe');
iframe.src = src;
iframe.allow = 'autoplay; encrypted-media';
iframe.allowFullscreen = true;
iframe.style.cssText = 'width:100%;height:100%;border:none';
div.appendChild(iframe);
} else if (isVideo) {
const video = document.createElement('video');
video.src = src;
video.autoplay = true;
video.muted = (zone.sort_order > 0); // Only first zone has audio
video.loop = (playlist.length === 1);
video.playsInline = true;
video.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
if (!video.loop) video.onended = () => nextItem();
div.appendChild(video);
} else {
const img = document.createElement('img');
img.src = src;
img.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
div.appendChild(img);
if (playlist.length > 1) setTimeout(nextItem, (assignment.duration_sec || 10) * 1000);
}
container.appendChild(div);
});
}
// ==================== Screenshots ====================
function captureAndSend() {
if (!socket?.connected) return;
const canvas = document.createElement('canvas');
canvas.width = 960;
canvas.height = 540;
const ctx = canvas.getContext('2d');
let captured = false;
try {
const container = document.getElementById('playerContainer');
const video = container?.querySelector('video');
const img = container?.querySelector('img');
// Try video first
if (video && video.readyState >= 2 && video.videoWidth > 0) {
try {
ctx.drawImage(video, 0, 0, 960, 540);
captured = true;
} catch (e) {
console.warn('Video capture failed (CORS?):', e.message);
}
}
// Try image
if (!captured && img && img.complete && img.naturalWidth > 0) {
try {
ctx.drawImage(img, 0, 0, 960, 540);
captured = true;
} catch (e) {
console.warn('Image capture failed:', e.message);
}
}
// Fallback: draw status info
if (!captured) {
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, 960, 540);
ctx.fillStyle = '#3b82f6';
ctx.font = 'bold 28px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('ScreenTinker Web Player', 480, 230);
ctx.fillStyle = '#94a3b8';
ctx.font = '16px sans-serif';
const item = playlist[currentIndex];
ctx.fillText(item ? `Playing: ${item.filename}` : 'No content', 480, 270);
ctx.fillText(`${config.deviceName || 'Web Player'} | ${new Date().toLocaleTimeString()}`, 480, 310);
}
} catch (e) {
// Even on error, draw something
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, 960, 540);
ctx.fillStyle = '#ef4444';
ctx.font = '16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Screenshot error: ' + e.message, 480, 270);
}
try {
const dataUrl = canvas.toDataURL('image/jpeg', 0.4);
const base64 = dataUrl.split(',')[1];
if (base64 && base64.length > 100) {
socket.emit('device:screenshot', { device_id: config.deviceId, image_b64: base64 });
console.log('Screenshot sent:', base64.length, 'chars', captured ? '(content)' : '(fallback)');
}
} catch (e) {
console.error('Screenshot encode/send failed:', e);
}
}
function startStreaming() {
stopStreaming();
streamTimer = setInterval(captureAndSend, 1000);
}
function stopStreaming() {
if (streamTimer) { clearInterval(streamTimer); streamTimer = null; }
}
// ==================== UI Helpers ====================
function showStatus(msg) {
document.getElementById('statusOverlay').style.display = 'flex';
document.getElementById('statusText').textContent = msg;
}
function hideStatus() {
document.getElementById('statusOverlay').style.display = 'none';
}
function toggleScreenOff() {
let overlay = document.getElementById('screenOffOverlay');
if (overlay) { overlay.remove(); return; }
overlay = document.createElement('div');
overlay.id = 'screenOffOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:9999;cursor:pointer';
overlay.onclick = () => overlay.remove();
document.body.appendChild(overlay);
}
// Create info overlay (toggled by Back button)
const infoDiv = document.createElement('div');
infoDiv.id = 'infoOverlay';
infoDiv.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:800;display:none;flex-direction:column;align-items:center;justify-content:center;color:#f1f5f9;font-family:-apple-system,sans-serif';
infoDiv.innerHTML = `
<h2 style="color:#3b82f6;margin-bottom:16px">ScreenTinker Web Player</h2>
<div style="font-size:14px;line-height:2;text-align:center;color:#94a3b8" id="infoContent"></div>
<p style="margin-top:24px;font-size:12px;color:#64748b">Press Back again or click to close</p>
`;
infoDiv.onclick = () => { infoDiv.style.display = 'none'; };
document.body.appendChild(infoDiv);
// Update info overlay content periodically
setInterval(() => {
const el = document.getElementById('infoContent');
if (!el) return;
const item = playlist[currentIndex];
el.innerHTML = `
Device ID: ${config.deviceId?.slice(0, 8) || 'N/A'}...<br>
Device Name: ${config.deviceName || 'N/A'}<br>
Server: ${config.serverUrl || 'N/A'}<br>
Status: ${socket?.connected ? '<span style="color:#22c55e">Connected</span>' : '<span style="color:#ef4444">Disconnected</span>'}<br>
Now Playing: ${item?.filename || 'Nothing'} (${currentIndex + 1}/${playlist.length})<br>
Resolution: ${screen.width}x${screen.height}<br>
Uptime: ${Math.floor(performance.now() / 60000)}m<br>
Platform: ${navigator.platform}<br>
Cache: Service Worker ${navigator.serviceWorker?.controller ? '<span style="color:#22c55e">Active</span>' : 'Inactive'}
`;
}, 2000);
// ==================== Fullscreen ====================
document.addEventListener('click', () => {
if (!document.fullscreenElement && config.paired) {
document.documentElement.requestFullscreen?.() ||
document.documentElement.webkitRequestFullscreen?.();
}
});
// Prevent sleep/screen saver
let wakeLock = null;
async function requestWakeLock() {
try {
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => { setTimeout(requestWakeLock, 1000); });
}
} catch {}
}
requestWakeLock();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') requestWakeLock(); });
// Register service worker for offline content caching
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/player/sw.js').then(
() => console.log('Service Worker registered'),
(err) => console.warn('SW registration failed:', err)
);
}
// ==================== Keyboard shortcuts ====================
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Reset config and go back to setup
if (confirm('Reset player and return to setup?')) {
localStorage.removeItem(STORAGE_KEY);
location.reload();
}
}
if (e.key === 'f' || e.key === 'F11') {
e.preventDefault();
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen();
}
});
</script>
</body>
</html>

53
server/player/sw.js Normal file
View file

@ -0,0 +1,53 @@
const CACHE_NAME = 'rd-player-v1';
const STATIC_ASSETS = ['/player/', '/player/index.html', '/socket.io/socket.io.js'];
// Install: cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
))
);
self.clients.claim();
});
// Fetch: cache content files for offline playback
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache content files (videos, images) on first fetch
if (url.pathname.startsWith('/uploads/content/')) {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
}).catch(() => new Response('Offline', { status: 503 }));
})
);
return;
}
// For static assets, try cache first
if (STATIC_ASSETS.some(a => url.pathname === a || url.pathname.endsWith(a))) {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
return;
}
// Everything else: network only
event.respondWith(fetch(event.request));
});

27
server/routes/activity.js Normal file
View file

@ -0,0 +1,27 @@
const express = require('express');
const router = express.Router();
const { getActivity, pruneActivityLog } = require('../services/activity');
// Get activity log
router.get('/', (req, res) => {
const { device_id, limit, offset } = req.query;
const isAdmin = req.user.role === 'superadmin';
const activity = getActivity({
userId: isAdmin ? null : req.user.id,
deviceId: device_id || null,
limit: Math.min(parseInt(limit) || 50, 200),
offset: parseInt(offset) || 0,
});
res.json(activity);
});
// Prune old logs (admin only)
router.delete('/prune', (req, res) => {
if (!['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Admin only' });
pruneActivityLog();
res.json({ success: true });
});
module.exports = router;

View file

@ -0,0 +1,175 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db/database');
// Check device ownership for device-scoped routes
function checkDeviceAccess(req, res) {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId);
if (!device) { res.status(404).json({ error: 'Device not found' }); return false; }
if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) {
res.status(403).json({ error: 'Access denied' }); return false;
}
return true;
}
// Get assignments for a device
router.get('/device/:deviceId', (req, res) => {
if (!checkDeviceAccess(req, res)) return;
const assignments = db.prepare(`
SELECT a.*,
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM assignments a
LEFT JOIN content c ON a.content_id = c.id
LEFT JOIN widgets w ON a.widget_id = w.id
WHERE a.device_id = ?
ORDER BY a.sort_order ASC
`).all(req.params.deviceId);
res.json(assignments);
});
// Add content or widget to device playlist
router.post('/device/:deviceId', (req, res) => {
if (!checkDeviceAccess(req, res)) return;
const { content_id, widget_id, zone_id, duration_sec = 10, sort_order, schedule_start, schedule_end, schedule_days } = req.body;
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
// Validate the referenced item exists AND belongs to the user
if (content_id) {
const content = db.prepare('SELECT id, user_id FROM content WHERE id = ?').get(content_id);
if (!content) return res.status(404).json({ error: 'Content not found' });
if (!['admin','superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) {
return res.status(403).json({ error: 'Content not owned by you' });
}
}
if (widget_id) {
const widget = db.prepare('SELECT id FROM widgets WHERE id = ?').get(widget_id);
if (!widget) return res.status(404).json({ error: 'Widget not found' });
}
// Get max sort order if not specified
let order = sort_order;
if (order === undefined || order === null) {
const max = db.prepare('SELECT MAX(sort_order) as max_order FROM assignments WHERE device_id = ?')
.get(req.params.deviceId);
order = (max.max_order || 0) + 1;
}
try {
const result = db.prepare(`
INSERT INTO assignments (device_id, content_id, widget_id, zone_id, sort_order, duration_sec, schedule_start, schedule_end, schedule_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(req.params.deviceId, content_id || null, widget_id || null, zone_id || null, order, duration_sec, schedule_start || null, schedule_end || null, schedule_days || null);
const assignment = db.prepare(`
SELECT a.*, c.filename as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM assignments a
LEFT JOIN content c ON a.content_id = c.id
LEFT JOIN widgets w ON a.widget_id = w.id
WHERE a.id = ?
`).get(result.lastInsertRowid);
res.status(201).json(assignment);
} catch (err) {
if (err.message.includes('UNIQUE')) {
return res.status(409).json({ error: 'Content already assigned to this device' });
}
throw err;
}
});
// Update assignment
router.put('/:id', (req, res) => {
const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id);
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
const { sort_order, duration_sec, schedule_start, schedule_end, schedule_days, enabled, zone_id } = req.body;
const updates = [];
const values = [];
if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); }
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(duration_sec); }
if (schedule_start !== undefined) { updates.push('schedule_start = ?'); values.push(schedule_start); }
if (schedule_end !== undefined) { updates.push('schedule_end = ?'); values.push(schedule_end); }
if (schedule_days !== undefined) { updates.push('schedule_days = ?'); values.push(schedule_days); }
if (enabled !== undefined) { updates.push('enabled = ?'); values.push(enabled); }
if (zone_id !== undefined) { updates.push('zone_id = ?'); values.push(zone_id || null); }
if (req.body.muted !== undefined) { updates.push('muted = ?'); values.push(req.body.muted ? 1 : 0); }
if (updates.length > 0) {
values.push(req.params.id);
db.prepare(`UPDATE assignments SET ${updates.join(', ')} WHERE id = ?`).run(...values);
}
const updated = db.prepare(`
SELECT a.*, COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM assignments a LEFT JOIN content c ON a.content_id = c.id LEFT JOIN widgets w ON a.widget_id = w.id
WHERE a.id = ?
`).get(req.params.id);
res.json(updated);
});
// Delete assignment
router.delete('/:id', (req, res) => {
const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id);
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id);
res.json({ success: true, device_id: assignment.device_id, content_id: assignment.content_id });
});
// Reorder assignments for a device
router.post('/device/:deviceId/reorder', (req, res) => {
const { order } = req.body; // Array of assignment IDs in desired order
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of assignment IDs' });
const updateStmt = db.prepare('UPDATE assignments SET sort_order = ? WHERE id = ? AND device_id = ?');
const transaction = db.transaction(() => {
order.forEach((assignmentId, index) => {
updateStmt.run(index, assignmentId, req.params.deviceId);
});
});
transaction();
const assignments = db.prepare(`
SELECT a.*, COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM assignments a LEFT JOIN content c ON a.content_id = c.id LEFT JOIN widgets w ON a.widget_id = w.id
WHERE a.device_id = ?
ORDER BY a.sort_order ASC
`).all(req.params.deviceId);
res.json(assignments);
});
// Copy playlist from one device to another
router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
const source = db.prepare('SELECT * FROM assignments WHERE device_id = ? AND enabled = 1 ORDER BY sort_order').all(req.params.deviceId);
if (!source.length) return res.status(404).json({ error: 'Source device has no assignments' });
const target = db.prepare('SELECT id FROM devices WHERE id = ?').get(req.params.targetDeviceId);
if (!target) return res.status(404).json({ error: 'Target device not found' });
// Clear existing assignments on target if requested
if (req.body.replace) {
db.prepare('DELETE FROM assignments WHERE device_id = ?').run(req.params.targetDeviceId);
}
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM assignments WHERE device_id = ?').get(req.params.targetDeviceId).m || 0;
const stmt = db.prepare('INSERT OR IGNORE INTO assignments (device_id, content_id, widget_id, zone_id, sort_order, duration_sec, enabled) VALUES (?, ?, ?, ?, ?, ?, 1)');
const transaction = db.transaction(() => {
source.forEach((a, i) => {
stmt.run(req.params.targetDeviceId, a.content_id, a.widget_id, a.zone_id, maxOrder + i + 1, a.duration_sec);
});
});
transaction();
res.json({ success: true, copied: source.length });
});
module.exports = router;

283
server/routes/auth.js Normal file
View file

@ -0,0 +1,283 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const https = require('https');
const { v4: uuidv4 } = require('uuid');
const { OAuth2Client } = require('google-auth-library');
const { db } = require('../db/database');
const { generateToken, requireAuth, requireAdmin, requireSuperAdmin } = require('../middleware/auth');
const config = require('../config');
function logFailedLogin(email, ip, reason) {
try {
db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (NULL, ?, ?, ?)')
.run('auth:login_failed', `${email} - ${reason}`, ip);
} catch {}
}
function logSuccessfulLogin(userId, email, ip) {
try {
db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)')
.run(userId, 'auth:login_success', email, ip);
db.prepare("UPDATE users SET last_login = strftime('%s','now') WHERE id = ?").run(userId);
} catch {}
}
// ==================== Local Auth ====================
// Register
router.post('/register', (req, res) => {
const { email, password, name } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (existing) return res.status(409).json({ error: 'Email already registered' });
const id = uuidv4();
const passwordHash = bcrypt.hashSync(password, 10);
// First user becomes admin with enterprise plan (self-hosted) or free plan with Pro trial
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user';
const isFirstUser = userCount === 0;
const plan = (isFirstUser && config.selfHosted) ? 'enterprise' : 'pro'; // Start on Pro trial
const trialStarted = isFirstUser && config.selfHosted ? null : Math.floor(Date.now() / 1000);
db.prepare(`
INSERT INTO users (id, email, name, password_hash, auth_provider, role, plan_id, trial_started, trial_plan)
VALUES (?, ?, ?, ?, 'local', ?, ?, ?, ?)
`).run(id, email.toLowerCase(), name || email.split('@')[0], passwordHash, role, plan, trialStarted, trialStarted ? 'pro' : null);
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(id);
const token = generateToken(user);
res.status(201).json({ token, user });
});
// Login
router.post('/login', (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
const user = db.prepare('SELECT * FROM users WHERE email = ? AND auth_provider = ?').get(email.toLowerCase(), 'local');
if (!user) {
logFailedLogin(email, req.ip, 'User not found');
return res.status(401).json({ error: 'Invalid email or password' });
}
if (!bcrypt.compareSync(password, user.password_hash)) {
logFailedLogin(email, req.ip, 'Wrong password');
return res.status(401).json({ error: 'Invalid email or password' });
}
logSuccessfulLogin(user.id, email, req.ip);
const token = generateToken(user);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser });
});
// ==================== Google OAuth ====================
router.post('/google', async (req, res) => {
const { credential } = req.body;
if (!credential) return res.status(400).json({ error: 'Google credential required' });
try {
// Verify the Google ID token
const payload = await verifyGoogleToken(credential);
if (!payload) return res.status(401).json({ error: 'Invalid Google token' });
const { email, name, picture, sub: googleId } = payload;
// Find or create user
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
if (!user) {
const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user';
const isFirst = userCount === 0;
const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000);
db.prepare(`
INSERT INTO users (id, email, name, auth_provider, provider_id, avatar_url, role, plan_id, trial_started, trial_plan)
VALUES (?, ?, ?, 'google', ?, ?, ?, ?, ?, ?)
`).run(id, email.toLowerCase(), name || '', googleId, picture || '', role, plan, trialStarted, trialStarted ? 'pro' : null);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
} else if (user.auth_provider !== 'google') {
// Link Google to existing account
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?')
.run('google', googleId, picture || user.avatar_url, user.id);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
}
const token = generateToken(user);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
console.error('Google auth error:', err);
res.status(401).json({ error: 'Google authentication failed' });
}
});
async function verifyGoogleToken(credential) {
const client = new OAuth2Client(config.googleClientId);
try {
const ticket = await client.verifyIdToken({
idToken: credential,
audience: config.googleClientId || undefined,
});
return ticket.getPayload();
} catch (e) {
// Fallback: if credential is an access token, verify via tokeninfo
try {
const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${credential}`);
if (!res.ok) throw new Error('Invalid token');
return await res.json();
} catch {
throw new Error('Google token verification failed: ' + e.message);
}
}
}
// ==================== Microsoft OAuth ====================
router.post('/microsoft', async (req, res) => {
const { access_token } = req.body;
if (!access_token) return res.status(400).json({ error: 'Microsoft access token required' });
try {
// Use the access token to get user profile from Microsoft Graph
const profile = await getMicrosoftProfile(access_token);
if (!profile || !profile.mail && !profile.userPrincipalName) {
return res.status(401).json({ error: 'Could not get Microsoft profile' });
}
const email = (profile.mail || profile.userPrincipalName).toLowerCase();
const name = profile.displayName || '';
const microsoftId = profile.id;
// Find or create user
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user';
const isFirst = userCount === 0;
const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro';
const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000);
db.prepare(`
INSERT INTO users (id, email, name, auth_provider, provider_id, role, plan_id, trial_started, trial_plan)
VALUES (?, ?, ?, 'microsoft', ?, ?, ?, ?, ?)
`).run(id, email, name, microsoftId, role, plan, trialStarted, trialStarted ? 'pro' : null);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
} else if (user.auth_provider !== 'microsoft') {
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ? WHERE id = ?')
.run('microsoft', microsoftId, user.id);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
}
const token = generateToken(user);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser });
} catch (err) {
console.error('Microsoft auth error:', err);
res.status(401).json({ error: 'Microsoft authentication failed' });
}
});
function getMicrosoftProfile(accessToken) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'graph.microsoft.com',
path: '/v1.0/me',
headers: { Authorization: `Bearer ${accessToken}` }
};
https.get(options, (resp) => {
let data = '';
resp.on('data', chunk => data += chunk);
resp.on('end', () => {
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
});
}).on('error', reject);
});
}
// ==================== User Management ====================
// Get current user
router.get('/me', requireAuth, (req, res) => {
res.json(req.user);
});
// Update current user
router.put('/me', requireAuth, (req, res) => {
const { name, password } = req.body;
if (name) {
db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(name, req.user.id);
}
if (password && password.length >= 6) {
const hash = bcrypt.hashSync(password, 10);
db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
.run(hash, req.user.id);
}
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(req.user.id);
res.json(user);
});
// List users - superadmins see all, admins see team members only
router.get('/users', requireAuth, requireAdmin, (req, res) => {
if (req.user.role === 'superadmin') {
const users = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, created_at, last_login FROM users ORDER BY created_at ASC').all();
res.json(users);
} else {
// Admin sees themselves + users in their teams
const users = db.prepare(`
SELECT DISTINCT u.id, u.email, u.name, u.role, u.auth_provider, u.avatar_url, u.plan_id, u.created_at
FROM users u
LEFT JOIN team_members tm ON u.id = tm.user_id
WHERE u.id = ? OR tm.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
ORDER BY u.created_at ASC
`).all(req.user.id, req.user.id);
res.json(users);
}
});
// Delete user (superadmin only)
router.delete('/users/:id', requireAuth, requireSuperAdmin, (req, res) => {
if (req.params.id === req.user.id) return res.status(400).json({ error: 'Cannot delete yourself' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// Update user role (superadmin only)
router.put('/users/:id/role', requireAuth, requireSuperAdmin, (req, res) => {
const { role } = req.body;
if (!['user', 'admin', 'superadmin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
if (req.params.id === req.user.id && role !== 'superadmin') return res.status(400).json({ error: 'Cannot demote yourself' });
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);
res.json({ success: true });
});
// Get auth config (public - tells frontend which providers are available)
router.get('/config', (req, res) => {
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
res.json({
googleEnabled: !!config.googleClientId,
googleClientId: config.googleClientId,
microsoftEnabled: !!config.microsoftClientId,
microsoftClientId: config.microsoftClientId,
microsoftTenantId: config.microsoftTenantId,
localEnabled: true,
needsSetup: userCount === 0,
});
});
module.exports = router;

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