mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Wall editor: replaces the small grid with a Figma-style pan/zoom canvas. Each display is a rectangle that can be dragged/resized to match its physical arrangement; a separate semi-transparent player rect overlays the screens and defines what content plays where. Drag empty space to pan, wheel to zoom, "Center" button auto-fits content. Per-rect numeric x/y/w/h panel; arrow keys nudge by 1px (10px with shift). Negative coordinates supported for screens offset above/left of the origin. Coords rounded to integers on save. Wall rendering: each device receives screen_rect + player_rect, maps the player into its viewport with vw/vh and object-fit:fill so vertical position of every source pixel is identical across devices that share viewport height. Leader emits wall:sync at 4Hz with sent_at timestamp; followers apply latency-adjusted target and use playbackRate ±3% for sub-300ms drift, hard-seek for >300ms. Followers stay muted; leader unmutes via gesture with AudioContext priming and pause+play retry to bypass Firefox autoplay. "Tap to enable audio" overlay as a final fallback. Reconnect handling: server re-evaluates leader on device:register so the top-left tile reclaims leadership when it returns. Followers emit wall:sync-request on entering wall mode (incl. reconnect) so they snap to position immediately instead of drifting until the next periodic tick. Group dissolve: removing a device from its last group clears its playlist to mirror wall-leave semantics. Leaving a group with playlists on remaining groups inherits the next group's playlist. Dashboard: walls render as their own card section (hidden the device cards they contain). Multi-select checkboxes on cards + "Create Video Wall" toolbar action that creates the wall, removes devices from groups, and opens the editor. dashboard:wall-changed broadcast triggers live re-render. Per-card playback progress bar driven by play_start events forwarded from devices. Security: PUT /walls/:id/devices verifies caller owns each device (or has team-owner access via the widgets pattern), preventing cross-tenant device takeover. wall:sync and wall:sync-request validate that the sending device is a member of the named wall; relay re-stamps device_id with currentDeviceId so clients can't spoof or shadow-exclude peers. Schema: video_walls += player_x/y/width/height, playlist_id; video_wall_devices += canvas_x/y/width/height. All idempotent migrations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
310 lines
12 KiB
JavaScript
310 lines
12 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { db } = require('../db/database');
|
|
|
|
// Visibility model (matches widgets/users):
|
|
// superadmin: all walls
|
|
// admin: own + walls owned by members of teams this admin owns
|
|
// user: own only
|
|
function listVisibleWalls(user) {
|
|
if (user.role === 'superadmin') {
|
|
return db.prepare('SELECT * FROM video_walls ORDER BY created_at DESC').all();
|
|
}
|
|
if (user.role === 'admin') {
|
|
return db.prepare(`
|
|
SELECT DISTINCT w.* FROM video_walls w
|
|
LEFT JOIN team_members tm_target ON w.user_id = tm_target.user_id
|
|
LEFT JOIN team_members tm_admin
|
|
ON tm_admin.team_id = tm_target.team_id
|
|
AND tm_admin.user_id = ?
|
|
AND tm_admin.role = 'owner'
|
|
WHERE w.user_id = ?
|
|
OR tm_admin.team_id IS NOT NULL
|
|
ORDER BY w.created_at DESC
|
|
`).all(user.id, user.id);
|
|
}
|
|
return db.prepare('SELECT * FROM video_walls WHERE user_id = ? ORDER BY created_at DESC').all(user.id);
|
|
}
|
|
|
|
function userCanAccessWall(user, wall) {
|
|
if (user.role === 'superadmin') return true;
|
|
if (wall.user_id === user.id) return true;
|
|
if (user.role === 'admin') {
|
|
const ownsTeamWithOwner = db.prepare(`
|
|
SELECT 1 FROM team_members tm_target
|
|
JOIN team_members tm_admin ON tm_admin.team_id = tm_target.team_id
|
|
WHERE tm_target.user_id = ? AND tm_admin.user_id = ? AND tm_admin.role = 'owner'
|
|
LIMIT 1
|
|
`).get(wall.user_id, user.id);
|
|
if (ownsTeamWithOwner) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// List walls (with attached devices)
|
|
router.get('/', (req, res) => {
|
|
const walls = listVisibleWalls(req.user);
|
|
|
|
const devStmt = db.prepare(`
|
|
SELECT vwd.*, d.name as device_name, d.status as device_status
|
|
FROM video_wall_devices vwd
|
|
JOIN devices d ON vwd.device_id = d.id
|
|
WHERE vwd.wall_id = ?
|
|
ORDER BY vwd.grid_row, vwd.grid_col
|
|
`);
|
|
walls.forEach(w => { w.devices = devStmt.all(w.id); });
|
|
|
|
res.json(walls);
|
|
});
|
|
|
|
function checkWallAccess(req, res) {
|
|
const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id);
|
|
if (!wall) { res.status(404).json({ error: 'Wall not found' }); return null; }
|
|
if (!userCanAccessWall(req.user, wall)) {
|
|
res.status(403).json({ error: 'Access denied' }); return null;
|
|
}
|
|
return wall;
|
|
}
|
|
|
|
// Notify dashboard clients to re-fetch walls/devices. Re-fetches re-apply
|
|
// per-user visibility filtering, so a broadcast is safe.
|
|
function notifyDashboards(req) {
|
|
try {
|
|
const io = req.app.get('io');
|
|
if (!io) return;
|
|
io.of('/dashboard').emit('dashboard:wall-changed');
|
|
} catch (e) { /* silent */ }
|
|
}
|
|
|
|
function loadWallWithDevices(id) {
|
|
const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(id);
|
|
if (!wall) return null;
|
|
wall.devices = db.prepare(`
|
|
SELECT vwd.*, d.name as device_name, d.status as device_status
|
|
FROM video_wall_devices vwd JOIN devices d ON vwd.device_id = d.id
|
|
WHERE vwd.wall_id = ? ORDER BY vwd.grid_row, vwd.grid_col
|
|
`).all(id);
|
|
return wall;
|
|
}
|
|
|
|
// Push a fresh wall-aware playlist payload to one device.
|
|
function pushWallPayloadToDevice(req, deviceId) {
|
|
try {
|
|
const io = req.app.get('io');
|
|
if (!io) return;
|
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
|
io.of('/device').to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
|
|
} catch (e) { /* silent */ }
|
|
}
|
|
|
|
function pushToWallMembers(req, wallId) {
|
|
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wallId);
|
|
for (const m of members) pushWallPayloadToDevice(req, m.device_id);
|
|
}
|
|
|
|
// Get wall with devices
|
|
router.get('/:id', (req, res) => {
|
|
const wall = checkWallAccess(req, res);
|
|
if (!wall) return;
|
|
res.json(loadWallWithDevices(wall.id));
|
|
});
|
|
|
|
// Create wall
|
|
router.post('/', (req, res) => {
|
|
const { name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, playlist_id } = req.body;
|
|
if (!name) return res.status(400).json({ error: 'name required' });
|
|
|
|
const id = uuidv4();
|
|
db.prepare(`
|
|
INSERT INTO video_walls (id, user_id, name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, playlist_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(id, req.user.id, name, grid_cols || 2, grid_rows || 1,
|
|
bezel_h_mm || 0, bezel_v_mm || 0, playlist_id || null);
|
|
|
|
const wall = loadWallWithDevices(id);
|
|
notifyDashboards(req);
|
|
res.status(201).json(wall);
|
|
});
|
|
|
|
// Update wall (name, grid, bezels, playlist, leader, sync_mode)
|
|
router.put('/:id', (req, res) => {
|
|
const wall = checkWallAccess(req, res);
|
|
if (!wall) return;
|
|
|
|
const fields = ['name', 'grid_cols', 'grid_rows', 'bezel_h_mm', 'bezel_v_mm',
|
|
'screen_w_mm', 'screen_h_mm', 'sync_mode', 'leader_device_id', 'content_id', 'playlist_id',
|
|
'player_x', 'player_y', 'player_width', 'player_height'];
|
|
const updates = [];
|
|
const values = [];
|
|
for (const f of fields) {
|
|
if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); }
|
|
}
|
|
|
|
if (updates.length > 0) {
|
|
updates.push("updated_at = strftime('%s','now')");
|
|
values.push(req.params.id);
|
|
db.prepare(`UPDATE video_walls SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
}
|
|
|
|
// If playlist changed, propagate to every member device's playlist_id so the
|
|
// existing buildPlaylistPayload picks up the right items.
|
|
if (req.body.playlist_id !== undefined) {
|
|
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id);
|
|
const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?');
|
|
for (const m of members) stmt.run(req.body.playlist_id || null, m.device_id);
|
|
}
|
|
|
|
pushToWallMembers(req, req.params.id);
|
|
notifyDashboards(req);
|
|
res.json(loadWallWithDevices(req.params.id));
|
|
});
|
|
|
|
// Delete wall — clear playlists + wall_id on every former member (matches
|
|
// group-dissolve semantics: leaving the wall returns devices to ungrouped).
|
|
router.delete('/:id', (req, res) => {
|
|
const wall = checkWallAccess(req, res);
|
|
if (!wall) return;
|
|
|
|
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id);
|
|
const tx = db.transaction(() => {
|
|
db.prepare("UPDATE devices SET wall_id = NULL, playlist_id = NULL WHERE wall_id = ?").run(req.params.id);
|
|
db.prepare('DELETE FROM video_walls WHERE id = ?').run(req.params.id);
|
|
});
|
|
tx();
|
|
|
|
// Push fresh (now wall-less, playlist-less) payloads to ex-members so they
|
|
// exit wall mode and clear content immediately.
|
|
for (const m of members) pushWallPayloadToDevice(req, m.device_id);
|
|
notifyDashboards(req);
|
|
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Set device grid positions. Replaces the entire member set.
|
|
// Devices removed lose their playlist (returned to ungrouped); devices added
|
|
// inherit the wall's playlist.
|
|
router.put('/:id/devices', (req, res) => {
|
|
const { devices } = req.body;
|
|
if (!Array.isArray(devices)) return res.status(400).json({ error: 'devices array required' });
|
|
|
|
const wall = checkWallAccess(req, res);
|
|
if (!wall) return;
|
|
|
|
// Verify caller owns (or has team access to) every device they're adding.
|
|
// Without this a user could attach another tenant's devices to their own
|
|
// wall and silently take over the playlist + wall_id on those rows.
|
|
// Mirrors the per-device check in device-groups.js.
|
|
if (!['superadmin'].includes(req.user.role)) {
|
|
const isAdmin = req.user.role === 'admin';
|
|
for (const d of devices) {
|
|
const dev = db.prepare('SELECT user_id, team_id FROM devices WHERE id = ?').get(d.device_id);
|
|
if (!dev) return res.status(404).json({ error: `Device ${d.device_id} not found` });
|
|
if (dev.user_id === req.user.id) continue;
|
|
if (isAdmin && dev.user_id) {
|
|
// Admin may attach team members' devices: dev's owner must be in a team this admin owns
|
|
const ownsTeamWithOwner = db.prepare(`
|
|
SELECT 1 FROM team_members tm_target
|
|
JOIN team_members tm_admin ON tm_admin.team_id = tm_target.team_id
|
|
WHERE tm_target.user_id = ? AND tm_admin.user_id = ? AND tm_admin.role = 'owner'
|
|
LIMIT 1
|
|
`).get(dev.user_id, req.user.id);
|
|
if (ownsTeamWithOwner) continue;
|
|
}
|
|
// Non-admin: must own the device directly
|
|
return res.status(403).json({ error: `Access denied to device ${d.device_id}` });
|
|
}
|
|
}
|
|
|
|
const previous = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(req.params.id);
|
|
const previousIds = new Set(previous.map(p => p.device_id));
|
|
const incomingIds = new Set(devices.map(d => d.device_id));
|
|
const removedIds = [...previousIds].filter(id => !incomingIds.has(id));
|
|
|
|
const tx = db.transaction(() => {
|
|
db.prepare('DELETE FROM video_wall_devices WHERE wall_id = ?').run(req.params.id);
|
|
db.prepare("UPDATE devices SET wall_id = NULL WHERE wall_id = ?").run(req.params.id);
|
|
|
|
// Removed devices: clear playlist (they're returning to ungrouped state).
|
|
for (const id of removedIds) {
|
|
db.prepare("UPDATE devices SET playlist_id = NULL WHERE id = ?").run(id);
|
|
}
|
|
|
|
const insertPos = db.prepare(`
|
|
INSERT INTO video_wall_devices
|
|
(wall_id, device_id, grid_col, grid_row, rotation, canvas_x, canvas_y, canvas_width, canvas_height)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
const updateDevice = db.prepare("UPDATE devices SET wall_id = ?, playlist_id = ? WHERE id = ?");
|
|
|
|
for (const d of devices) {
|
|
insertPos.run(
|
|
req.params.id, d.device_id,
|
|
d.grid_col, d.grid_row, d.rotation || 0,
|
|
d.canvas_x ?? null, d.canvas_y ?? null,
|
|
d.canvas_width ?? null, d.canvas_height ?? null,
|
|
);
|
|
updateDevice.run(req.params.id, wall.playlist_id || null, d.device_id);
|
|
// A device joining a wall leaves all of its groups (walls and groups
|
|
// are mutually exclusive concepts in this UX).
|
|
db.prepare('DELETE FROM device_group_members WHERE device_id = ?').run(d.device_id);
|
|
}
|
|
|
|
if (devices.length > 0) {
|
|
// Prefer the device whose canvas rect is closest to the wall's top-left
|
|
// (smallest canvas_x + canvas_y), falling back to grid 0,0, then first.
|
|
const leader =
|
|
[...devices].sort((a, b) => ((a.canvas_x ?? 0) + (a.canvas_y ?? 0)) - ((b.canvas_x ?? 0) + (b.canvas_y ?? 0)))[0]
|
|
|| devices.find(d => d.grid_col === 0 && d.grid_row === 0)
|
|
|| devices[0];
|
|
db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(leader.device_id, req.params.id);
|
|
} else {
|
|
db.prepare('UPDATE video_walls SET leader_device_id = NULL WHERE id = ?').run(req.params.id);
|
|
}
|
|
});
|
|
tx();
|
|
|
|
// Push wall-aware payload to current members, and a wall-less payload to
|
|
// ex-members so they exit wall mode.
|
|
for (const id of removedIds) pushWallPayloadToDevice(req, id);
|
|
pushToWallMembers(req, req.params.id);
|
|
notifyDashboards(req);
|
|
|
|
res.json(loadWallWithDevices(req.params.id));
|
|
});
|
|
|
|
// Set wall content (legacy single-video path — kept for back-compat)
|
|
router.put('/:id/content', (req, res) => {
|
|
const wall = checkWallAccess(req, res);
|
|
if (!wall) return;
|
|
const { content_id } = req.body;
|
|
db.prepare("UPDATE video_walls SET content_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
|
.run(content_id || null, req.params.id);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Get wall config for a specific device (legacy fetch path)
|
|
router.get('/:id/device-config/:deviceId', (req, res) => {
|
|
const wall = checkWallAccess(req, res);
|
|
if (!wall) return;
|
|
|
|
const position = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ? AND device_id = ?')
|
|
.get(req.params.id, req.params.deviceId);
|
|
if (!position) return res.status(404).json({ error: 'Device not in this wall' });
|
|
|
|
res.json({
|
|
wall_id: wall.id,
|
|
grid_cols: wall.grid_cols,
|
|
grid_rows: wall.grid_rows,
|
|
grid_col: position.grid_col,
|
|
grid_row: position.grid_row,
|
|
rotation: position.rotation,
|
|
bezel_h_px: wall.bezel_h_mm,
|
|
bezel_v_px: wall.bezel_v_mm,
|
|
sync_mode: wall.sync_mode,
|
|
is_leader: wall.leader_device_id === req.params.deviceId,
|
|
});
|
|
});
|
|
|
|
module.exports = router;
|