screentinker/server/routes/device-groups.js
ScreenTinker 2068bc8833 Video walls: free-form canvas editor, leader-driven sync, group dissolve, progress bars
Wall editor: replaces the small grid with a Figma-style pan/zoom canvas. Each
display is a rectangle that can be dragged/resized to match its physical
arrangement; a separate semi-transparent player rect overlays the screens and
defines what content plays where. Drag empty space to pan, wheel to zoom,
"Center" button auto-fits content. Per-rect numeric x/y/w/h panel; arrow keys
nudge by 1px (10px with shift). Negative coordinates supported for screens
offset above/left of the origin. Coords rounded to integers on save.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 23:11:16 -05:00

271 lines
12 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
const ALLOWED_COMMANDS = ['screen_on', 'screen_off', 'launch', 'update', 'reboot', 'shutdown'];
// Verify group belongs to the authenticated user
function requireGroupOwnership(req, res, next) {
const group = db.prepare('SELECT * FROM device_groups WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!group) return res.status(404).json({ error: 'group not found' });
req.group = group;
next();
}
// List groups
router.get('/', (req, res) => {
const groups = db.prepare(`
SELECT g.*, COUNT(dgm.device_id) as device_count
FROM device_groups g
LEFT JOIN device_group_members dgm ON g.id = dgm.group_id
WHERE g.user_id = ?
GROUP BY g.id
ORDER BY g.name ASC
`).all(req.user.id);
res.json(groups);
});
// Create group
router.post('/', (req, res) => {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'name required' });
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
const id = uuidv4();
db.prepare('INSERT INTO device_groups (id, user_id, name, color) VALUES (?, ?, ?, ?)')
.run(id, req.user.id, name, color || '#3B82F6');
res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id));
});
// Update group
router.put('/:id', requireGroupOwnership, (req, res) => {
const { name, color } = req.body;
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id);
if (color) db.prepare('UPDATE device_groups SET color = ? WHERE id = ?').run(color, req.params.id);
res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
});
// Delete group — converts group schedules to per-device schedules first
router.delete('/:id', requireGroupOwnership, (req, res) => {
const groupId = req.params.id;
const convert = db.transaction(() => {
// Find group schedules that need conversion
const groupSchedules = db.prepare('SELECT * FROM schedules WHERE group_id = ?').all(groupId);
// Find current group members
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(groupId);
let converted = 0;
if (groupSchedules.length > 0 && members.length > 0) {
const insert = db.prepare(`
INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id,
widget_id, layout_id, playlist_id, title, start_time, end_time, timezone,
recurrence, recurrence_end, priority, enabled, color, created_at, updated_at)
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const schedule of groupSchedules) {
for (const member of members) {
insert.run(
uuidv4(), schedule.user_id, member.device_id,
schedule.zone_id, schedule.content_id, schedule.widget_id,
schedule.layout_id, schedule.playlist_id, schedule.title,
schedule.start_time, schedule.end_time, schedule.timezone,
schedule.recurrence, schedule.recurrence_end, schedule.priority,
schedule.enabled, schedule.color, schedule.created_at, schedule.updated_at
);
}
converted++;
}
}
// Delete group schedules explicitly (before group delete turns group_id to NULL via ON DELETE SET NULL)
db.prepare('DELETE FROM schedules WHERE group_id = ?').run(groupId);
// Delete the group (cascades to device_group_members)
db.prepare('DELETE FROM device_groups WHERE id = ?').run(groupId);
return { converted, devices: members.length };
});
const result = convert();
res.json({ success: true, schedules_converted: result.converted, devices: result.devices });
});
// Get devices in a group
router.get('/:id/devices', requireGroupOwnership, (req, res) => {
const devices = db.prepare(`
SELECT d.* FROM devices d
JOIN device_group_members dgm ON d.id = dgm.device_id
WHERE dgm.group_id = ?
ORDER BY d.name ASC
`).all(req.params.id);
res.json(devices);
});
// Add device to group. If the group has a playlist set (via the assign-playlist
// dropdown on the dashboard), the new device inherits it — both for drag-drop
// onto the group section and for the Manage modal's checkboxes, which both
// hit this endpoint. Without this, joining a group never auto-assigned the
// group's playlist, leaving the new device on whatever it had before.
router.post('/:id/devices', requireGroupOwnership, (req, res) => {
const { device_id } = req.body;
if (!device_id) return res.status(400).json({ error: 'device_id required' });
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
try {
db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id);
// Sync device's playlist to the group's: a defined playlist is inherited,
// a group with no playlist clears the device's. The user's mental model
// is "joining a group means using its playlist (or none)" — staying on a
// stale playlist after joining a no-playlist group was the bug we just hit.
const group = db.prepare('SELECT playlist_id FROM device_groups WHERE id = ?').get(req.params.id);
const newPlaylist = group?.playlist_id || null;
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, device_id);
pushPlaylistToDevice(req, device_id);
res.status(201).json({ success: true, playlist_id: newPlaylist });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// Remove device from group. Sync the device's playlist to whatever its
// current group membership implies — symmetric with the join sync above.
// - No remaining groups → clear playlist (Ungrouped).
// - Remaining group with a playlist → adopt that playlist.
// - Remaining group(s) but none have a playlist → clear playlist.
// Without this, a device dragged out of a group keeps stale playlist state
// from the group it just left.
router.delete('/:id/devices/:deviceId', requireGroupOwnership, (req, res) => {
const deviceId = req.params.deviceId;
db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(deviceId, req.params.id);
const remaining = db.prepare(`
SELECT g.playlist_id FROM device_groups g
JOIN device_group_members dgm ON g.id = dgm.group_id
WHERE dgm.device_id = ?
ORDER BY g.playlist_id IS NULL, g.name ASC
LIMIT 1
`).get(deviceId);
const newPlaylist = remaining?.playlist_id || null;
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(newPlaylist, deviceId);
pushPlaylistToDevice(req, deviceId);
res.json({ success: true });
});
// Ensure a device has a playlist; auto-create one if missing
function ensureDevicePlaylist(deviceId, userId) {
const device = db.prepare('SELECT playlist_id, name FROM devices WHERE id = ?').get(deviceId);
if (device?.playlist_id) return device.playlist_id;
const playlistId = uuidv4();
db.prepare('INSERT INTO playlists (id, user_id, name, is_auto_generated) VALUES (?, ?, ?, 1)')
.run(playlistId, userId, `${device?.name || 'Display'} playlist`);
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId);
return playlistId;
}
// Mark playlist as draft (called after any item mutation)
function markDraft(playlistId) {
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
}
// Push playlist update to a device (used by assign-playlist which doesn't modify items)
function pushPlaylistToDevice(req, deviceId) {
try {
const io = req.app.get('io');
if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const deviceNs = io.of('/device');
deviceNs.to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
} catch (e) { /* silent */ }
}
// Bulk assign content to all devices in a group (adds to each device's playlist)
router.post('/:id/assign-content', requireGroupOwnership, (req, res) => {
const { content_id, duration_sec } = req.body;
if (!content_id) return res.status(400).json({ error: 'content_id required' });
// Verify content belongs to the user
const content = db.prepare('SELECT id FROM content WHERE id = ? AND user_id = ?').get(content_id, req.user.id);
if (!content) return res.status(404).json({ error: 'Content not found' });
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
const transaction = db.transaction(() => {
for (const m of members) {
const playlistId = ensureDevicePlaylist(m.device_id, req.user.id);
const max = db.prepare('SELECT COALESCE(MAX(sort_order),0)+1 as next FROM playlist_items WHERE playlist_id = ?').get(playlistId);
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)')
.run(playlistId, content_id, max.next, duration_sec || 10);
markDraft(playlistId);
}
});
transaction();
res.json({ success: true, devices_updated: members.length });
});
// Assign an existing playlist to all devices in a group, and persist the
// choice on the group itself so future joiners inherit it (see POST /:id/devices).
router.post('/:id/assign-playlist', requireGroupOwnership, (req, res) => {
const { playlist_id } = req.body;
if (!playlist_id) return res.status(400).json({ error: 'playlist_id required' });
const playlist = db.prepare('SELECT id FROM playlists WHERE id = ? AND user_id = ?').get(playlist_id, req.user.id);
if (!playlist) return res.status(404).json({ error: 'Playlist not found' });
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
const stmt = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?');
const transaction = db.transaction(() => {
db.prepare('UPDATE device_groups SET playlist_id = ? WHERE id = ?').run(playlist_id, req.params.id);
for (const m of members) stmt.run(playlist_id, m.device_id);
});
transaction();
for (const m of members) pushPlaylistToDevice(req, m.device_id);
res.json({ success: true, devices_updated: members.length });
});
// Send command to all devices in a group
router.post('/:id/command', requireGroupOwnership, (req, res) => {
const { type, payload } = req.body;
if (!type) return res.status(400).json({ error: 'command type required' });
if (!ALLOWED_COMMANDS.includes(type)) return res.status(400).json({ error: 'invalid command type' });
const devices = db.prepare(`
SELECT d.id, d.name, d.status FROM devices d
JOIN device_group_members dgm ON d.id = dgm.device_id
WHERE dgm.group_id = ?
`).all(req.params.id);
const deviceNs = req.app.get('io').of('/device');
const results = [];
for (const device of devices) {
const room = deviceNs.adapter.rooms.get(device.id);
if (room && room.size > 0) {
deviceNs.to(device.id).emit('device:command', { type, payload: payload || {} });
results.push({ device_id: device.id, name: device.name, status: 'sent' });
} else {
results.push({ device_id: device.id, name: device.name, status: 'offline' });
}
}
const sent = results.filter(r => r.status === 'sent').length;
const offline = results.filter(r => r.status === 'offline').length;
console.log(`Group command '${type}' sent to group '${req.group.name}': ${sent} sent, ${offline} offline`);
res.json({ success: true, sent, offline, total: devices.length, results });
});
module.exports = router;