screentinker/frontend/js/socket.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

127 lines
3.7 KiB
JavaScript

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);
});
// Playback progress (play_start with duration — drives device-card progress bars)
dashboardSocket.on('dashboard:playback-progress', (data) => {
emit('playback-progress', data);
});
// Wall changed — dashboard refreshes wall cards + device-grouping layout
dashboardSocket.on('dashboard:wall-changed', () => {
emit('wall-changed');
});
// Content ack
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; }