mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-14 23:22:24 -06:00
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:
commit
1594a9d4a4
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal 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
53
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal 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
283
README.md
Normal 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)
|
||||
78
android/app/build.gradle.kts
Normal file
78
android/app/build.gradle.kts
Normal 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
3
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Socket.IO
|
||||
-keep class io.socket.** { *; }
|
||||
-keep class okhttp3.** { *; }
|
||||
112
android/app/src/main/AndroidManifest.xml
Normal file
112
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
10
android/app/src/main/res/drawable/button_primary.xml
Normal file
10
android/app/src/main/res/drawable/button_primary.xml
Normal 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>
|
||||
7
android/app/src/main/res/drawable/input_background.xml
Normal file
7
android/app/src/main/res/drawable/input_background.xml
Normal 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>
|
||||
79
android/app/src/main/res/layout/activity_main.xml
Normal file
79
android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
123
android/app/src/main/res/layout/activity_provisioning.xml
Normal file
123
android/app/src/main/res/layout/activity_provisioning.xml
Normal 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>
|
||||
215
android/app/src/main/res/layout/activity_setup.xml
Normal file
215
android/app/src/main/res/layout/activity_setup.xml
Normal 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>
|
||||
9
android/app/src/main/res/layout/zone_player.xml
Normal file
9
android/app/src/main/res/layout/zone_player.xml
Normal 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" />
|
||||
5
android/app/src/main/res/values/colors.xml
Normal file
5
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
5
android/app/src/main/res/values/strings.xml
Normal file
5
android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
19
android/app/src/main/res/values/themes.xml
Normal file
19
android/app/src/main/res/values/themes.xml
Normal 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>
|
||||
|
|
@ -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" />
|
||||
5
android/app/src/main/res/xml/file_paths.xml
Normal file
5
android/app/src/main/res/xml/file_paths.xml
Normal 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
5
android/build.gradle.kts
Normal 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
|
||||
}
|
||||
3
android/gradle.properties
Normal file
3
android/gradle.properties
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
249
android/gradlew
vendored
Executable 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
92
android/gradlew.bat
vendored
Normal 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
|
||||
17
android/settings.gradle.kts
Normal file
17
android/settings.gradle.kts
Normal 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")
|
||||
BIN
frontend/assets/icon-192.png
Normal file
BIN
frontend/assets/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/assets/icon-512.png
Normal file
BIN
frontend/assets/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
928
frontend/css/main.css
Normal file
928
frontend/css/main.css
Normal 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
21
frontend/css/reset.css
Normal 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; }
|
||||
27
frontend/css/variables.css
Normal file
27
frontend/css/variables.css
Normal 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
182
frontend/index.html
Normal 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
101
frontend/js/api.js
Normal 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
259
frontend/js/app.js
Normal 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();
|
||||
20
frontend/js/components/toast.js
Normal file
20
frontend/js/components/toast.js
Normal 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
158
frontend/js/i18n.js
Normal 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
116
frontend/js/socket.js
Normal 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; }
|
||||
101
frontend/js/views/activity.js
Normal file
101
frontend/js/views/activity.js
Normal 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 '🗑';
|
||||
if (action.includes('POST') && action.includes('content')) return '📤';
|
||||
if (action.includes('POST') && action.includes('provision')) return '🔗';
|
||||
if (action.includes('POST') && action.includes('assignment')) return '📋';
|
||||
if (action.includes('alert')) return '🔔';
|
||||
if (action.includes('PUT')) return '✎';
|
||||
if (action.includes('POST')) return '➕';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
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
170
frontend/js/views/admin.js
Normal 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() {}
|
||||
147
frontend/js/views/billing.js
Normal file
147
frontend/js/views/billing.js
Normal 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">⏱</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)">✓ Remote Control</div>' : '<div style="color:var(--text-muted)">✗ Remote Control</div>'}
|
||||
${subData.plan.remote_url ? '<div style="color:var(--success)">✓ Remote URLs</div>' : '<div style="color:var(--text-muted)">✗ Remote URLs</div>'}
|
||||
${subData.plan.priority_support ? '<div style="color:var(--success)">✓ Priority Support</div>' : '<div style="color:var(--text-muted)">✗ 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 ? '✓' : '✗'} Remote Control</div>
|
||||
<div>${p.remote_url ? '✓' : '✗'} Remote URLs</div>
|
||||
<div>${p.priority_support ? '✓' : '✗'} 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() {}
|
||||
493
frontend/js/views/content-library.js
Normal file
493
frontend/js/views/content-library.js
Normal 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 ? ` · ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''}
|
||||
${c.file_size ? ' · ' + formatFileSize(c.file_size) : ''}
|
||||
${c.width && c.height ? ` · ${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">×</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() {}
|
||||
288
frontend/js/views/dashboard.js
Normal file
288
frontend/js/views/dashboard.js
Normal 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;
|
||||
}
|
||||
560
frontend/js/views/designer.js
Normal file
560
frontend/js/views/designer.js
Normal 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">💬 Text</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addHeading" style="justify-content:center">📜 Heading</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addImage" style="justify-content:center">📷 Image</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addVideo" style="justify-content:center">🎬 Video</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addClock" style="justify-content:center">🕓 Clock</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addDate" style="justify-content:center">📅 Date</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addWeather" style="justify-content:center">⛅ Weather</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addTicker" style="justify-content:center">📰 Ticker</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addShape" style="justify-content:center">■ Shape</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addQR" style="justify-content:center">▩ QR Code</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addCountdown" style="justify-content:center">⏱ Countdown</button>
|
||||
<button class="btn btn-secondary btn-sm" id="addWebpage" style="justify-content:center">🌐 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}">⛅ 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 = '⛅ ' + 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: '💬', clock: '🕓', date: '📅', image: '📷', video: '🎬', shape: '■', weather: '⛅', ticker: '📰', qr: '▩', countdown: '⏱', webpage: '🌐' };
|
||||
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() {}
|
||||
1149
frontend/js/views/device-detail.js
Normal file
1149
frontend/js/views/device-detail.js
Normal file
File diff suppressed because it is too large
Load diff
58
frontend/js/views/help.js
Normal file
58
frontend/js/views/help.js
Normal 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: '📺', 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: '📤', 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: '⚙', 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: '📋', 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: '📅', 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: '🖥', 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: '🖱', 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: '🎬', 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
201
frontend/js/views/kiosk.js
Normal 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">🖱</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">✕</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: '⭐', 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() {}
|
||||
309
frontend/js/views/layout-editor.js
Normal file
309
frontend/js/views/layout-editor.js
Normal 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
292
frontend/js/views/login.js
Normal 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>
|
||||
·
|
||||
<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() {}
|
||||
241
frontend/js/views/onboarding.js
Normal file
241
frontend/js/views/onboarding.js
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { showToast } from '../components/toast.js';
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: 'Welcome to ScreenTinker!',
|
||||
icon: '👋',
|
||||
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: '📥',
|
||||
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">🤖</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">🌐</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: '🔗',
|
||||
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: '📤',
|
||||
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">📁</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: '🎉',
|
||||
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() {}
|
||||
188
frontend/js/views/reports.js
Normal file
188
frontend/js/views/reports.js
Normal 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() {}
|
||||
200
frontend/js/views/schedule.js
Normal file
200
frontend/js/views/schedule.js
Normal 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">< Prev</button>
|
||||
<span id="weekLabel" style="color:var(--text-secondary);font-size:13px"></span>
|
||||
<button class="btn btn-secondary btn-sm" id="nextWeek">Next ></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() {}
|
||||
457
frontend/js/views/settings.js
Normal file
457
frontend/js/views/settings.js
Normal 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">🤖</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">🌐</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">🥏</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">💻</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">📺</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>
|
||||
·
|
||||
<a href="/legal/privacy.html" target="_blank" style="color:var(--accent);font-size:12px">Privacy Policy</a>
|
||||
·
|
||||
<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 => '• ' + 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
202
frontend/js/views/teams.js
Normal 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} · ${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">✕</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">✕</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() {}
|
||||
213
frontend/js/views/video-wall.js
Normal file
213
frontend/js/views/video-wall.js
Normal 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() {}
|
||||
216
frontend/js/views/widgets.js
Normal file
216
frontend/js/views/widgets.js
Normal 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: '🕓', desc: 'Digital clock with date' },
|
||||
{ id: 'weather', name: 'Weather', icon: '⛅', desc: 'Current weather conditions' },
|
||||
{ id: 'rss', name: 'News Ticker', icon: '📰', desc: 'Scrolling RSS feed' },
|
||||
{ id: 'text', name: 'Text/HTML', icon: '📝', desc: 'Custom text or HTML content' },
|
||||
{ id: 'webpage', name: 'Webpage', icon: '🌐', desc: 'Embed a webpage' },
|
||||
{ id: 'social', name: 'Social Feed', icon: '💬', 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
390
frontend/landing.html
Normal 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">⚡ 14-day free Pro trial · 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">📺</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">🎬</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">🖥</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">📅</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">🔧</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">🖱</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">📊</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">🔔</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">🔒</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">🎨</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">👥</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">🔄</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">🤖</div><div class="name">Android TV</div></div>
|
||||
<div class="platform-item"><div class="icon">🔥</div><div class="name">Fire TV</div></div>
|
||||
<div class="platform-item"><div class="icon">🥏</div><div class="name">Raspberry Pi</div></div>
|
||||
<div class="platform-item"><div class="icon">💻</div><div class="name">Windows</div></div>
|
||||
<div class="platform-item"><div class="icon">🌐</div><div class="name">ChromeOS</div></div>
|
||||
<div class="platform-item"><div class="icon">📺</div><div class="name">LG webOS</div></div>
|
||||
<div class="platform-item"><div class="icon">📺</div><div class="name">Samsung Tizen</div></div>
|
||||
<div class="platform-item"><div class="icon">🌎</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">✓ 1 device</td><td class="yes">✓</td><td class="no">✗</td><td class="yes">✓</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">✓ Included</td><td class="no">✗</td><td class="no">✗</td><td class="paid">Paid add-on</td></tr>
|
||||
<tr><td>Remote Control</td><td class="yes">✓ All plans</td><td class="paid">Paid add-on</td><td class="no">✗</td><td class="no">✗</td></tr>
|
||||
<tr><td>Content Designer</td><td class="yes">✓ Built-in</td><td class="no">✗</td><td class="no">✗</td><td class="no">✗</td></tr>
|
||||
<tr><td>Kiosk/Touchscreen</td><td class="yes">✓ Included</td><td class="no">✗</td><td class="no">✗</td><td class="no">✗</td></tr>
|
||||
<tr><td>Proof-of-Play</td><td class="yes">✓ 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">✓ Only us</td><td class="no">✗</td><td class="no">✗</td><td class="no">✗</td></tr>
|
||||
<tr><td>White Label</td><td class="yes">✓ Included</td><td class="paid">Paid</td><td class="paid">Enterprise</td><td class="no">✗</td></tr>
|
||||
<tr><td>Email Alerts</td><td class="yes">✓ 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">© 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"> </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
143
frontend/legal/privacy.html
Normal 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">← 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
138
frontend/legal/terms.html
Normal 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">← 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>
|
||||
107
frontend/legal/third-party.html
Normal file
107
frontend/legal/third-party.html
Normal 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">← 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
22
frontend/manifest.json
Normal 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
11
frontend/robots.txt
Normal 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
18
frontend/sitemap.xml
Normal 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
21
frontend/sw-admin.js
Normal 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)));
|
||||
});
|
||||
17
scripts/install-service.sh
Normal file
17
scripts/install-service.sh
Normal 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"
|
||||
106
scripts/raspberry-pi-setup.sh
Normal file
106
scripts/raspberry-pi-setup.sh
Normal 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."
|
||||
19
scripts/remotedisplay.service
Normal file
19
scripts/remotedisplay.service
Normal 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
44
scripts/reset-admin.js
Normal 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
37
scripts/windows-setup.bat
Normal 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
42
server/config.js
Normal 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
96
server/db/database.js
Normal 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
385
server/db/schema.sql
Normal 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
67
server/middleware/auth.js
Normal 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 };
|
||||
25
server/middleware/sanitize.js
Normal file
25
server/middleware/sanitize.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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 };
|
||||
145
server/middleware/subscription.js
Normal file
145
server/middleware/subscription.js
Normal 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
|
||||
};
|
||||
35
server/middleware/upload.js
Normal file
35
server/middleware/upload.js
Normal 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
3682
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
server/package.json
Normal file
27
server/package.json
Normal 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
788
server/player/index.html
Normal 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">⚠</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
53
server/player/sw.js
Normal 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
27
server/routes/activity.js
Normal 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;
|
||||
175
server/routes/assignments.js
Normal file
175
server/routes/assignments.js
Normal 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
283
server/routes/auth.js
Normal 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
Loading…
Reference in a new issue