Add group-level scheduling, group playlist assignment, and persist audio unlock

Phase 4 group scheduling: schema migration adds group_id to schedules with
CHECK constraint, scheduler evaluates group+device schedules with priority,
group deletion converts schedules to per-device copies. Dashboard gets
playlist assignment dropdown and current playlist label on group headers.
Player persists audio unlock state in localStorage so version reloads
don't lose audio on unattended displays.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-15 20:22:42 -05:00
parent 2104c9cc9f
commit 52dd44a3e8
9 changed files with 411 additions and 76 deletions

View file

@ -105,17 +105,35 @@ function renderDeviceCard(device) {
`; `;
} }
function renderGroupSection(group, devices) { function getGroupPlaylistLabel(devices, playlists) {
const playlistMap = new Map((playlists || []).map(p => [p.id, p]));
const assigned = devices.filter(d => d.playlist_id).map(d => d.playlist_id);
if (assigned.length === 0) return '';
const unique = [...new Set(assigned)];
if (unique.length === 1) {
const pl = playlistMap.get(unique[0]);
return pl ? esc(pl.name) : 'Unknown playlist';
}
return 'Mixed playlists';
}
function renderGroupSection(group, devices, playlists) {
const onlineCount = devices.filter(d => d.status === 'online').length; const onlineCount = devices.filter(d => d.status === 'online').length;
const playlistLabel = getGroupPlaylistLabel(devices, playlists);
return ` return `
<div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px"> <div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#3B82F6')}"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${esc(group.color || '#3B82F6')}">
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px">
<strong style="font-size:15px">${esc(group.name)}</strong> <strong style="font-size:15px">${esc(group.name)}</strong>
<span style="color:var(--text-muted);font-size:12px">${devices.length} device${devices.length !== 1 ? 's' : ''} &middot; ${onlineCount} online</span> <span style="color:var(--text-muted);font-size:12px">${devices.length} device${devices.length !== 1 ? 's' : ''} &middot; ${onlineCount} online</span>
${playlistLabel ? `<span style="font-size:11px;color:var(--text-secondary);background:var(--bg-primary);padding:2px 8px;border-radius:10px">Playlist: ${playlistLabel}</span>` : ''}
</div> </div>
<div style="display:flex;gap:6px;align-items:center"> <div style="display:flex;gap:6px;align-items:center">
${devices.length > 0 ? ` ${devices.length > 0 ? `
<select class="input group-playlist-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" style="width:160px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
<option value="">Set Playlist...</option>
${(playlists || []).map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' (draft)' : ''}</option>`).join('')}
</select>
<select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)"> <select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${esc(group.name)}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
<option value="">Send Command...</option> <option value="">Send Command...</option>
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${c.label}</option>`).join('')} ${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${c.label}</option>`).join('')}
@ -268,7 +286,7 @@ async function loadDashboard() {
if (!main) return; if (!main) return;
try { try {
const [devices, groups] = await Promise.all([api.getDevices(), api.getGroups()]); const [devices, groups, playlists] = await Promise.all([api.getDevices(), api.getGroups(), api.getPlaylists()]);
// Stats // Stats
const online = devices.filter(d => d.status === 'online').length; const online = devices.filter(d => d.status === 'online').length;
@ -330,7 +348,7 @@ async function loadDashboard() {
// Render each group with its devices // Render each group with its devices
for (const g of groupsWithDevices) { for (const g of groupsWithDevices) {
html += renderGroupSection(g, g.devices); html += renderGroupSection(g, g.devices, playlists);
} }
// Render ungrouped devices // Render ungrouped devices
@ -358,6 +376,30 @@ async function loadDashboard() {
} }
function attachGroupHandlers(groupsWithDevices, allDevices) { function attachGroupHandlers(groupsWithDevices, allDevices) {
// Playlist assignment handlers
document.querySelectorAll('.group-playlist-select').forEach(select => {
select.addEventListener('change', async (e) => {
const playlistId = e.target.value;
if (!playlistId) return;
const groupId = e.target.dataset.groupId;
const groupName = e.target.dataset.groupName;
const playlistName = e.target.options[e.target.selectedIndex].textContent;
if (!confirm(`Assign playlist "${playlistName}" to all devices in "${groupName}"?`)) {
e.target.value = '';
return;
}
try {
const result = await api.groupAssignPlaylist(groupId, playlistId);
showToast(`Playlist assigned to ${result.devices_updated} device${result.devices_updated !== 1 ? 's' : ''}`, 'success');
} catch (err) {
showToast(err.message, 'error');
}
e.target.value = '';
});
});
// Command select handlers // Command select handlers
document.querySelectorAll('.group-cmd-select').forEach(select => { document.querySelectorAll('.group-cmd-select').forEach(select => {
select.addEventListener('change', async (e) => { select.addEventListener('change', async (e) => {

View file

@ -6,9 +6,17 @@ const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type':
const HOURS = Array.from({ length: 24 }, (_, i) => i); const HOURS = Array.from({ length: 24 }, (_, i) => i);
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function esc(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
export async function render(container) { export async function render(container) {
const devices = await api.getDevices(); const [devices, content, groups, playlists, layoutsRaw] = await Promise.all([
const content = await api.getContent(); api.getDevices(),
api.getContent(),
api.getGroups(),
api.getPlaylists(),
API('/layouts'),
]);
const layouts = (Array.isArray(layoutsRaw) ? layoutsRaw : []).filter(l => !l.is_template);
const selectedDevice = devices[0]?.id || ''; const selectedDevice = devices[0]?.id || '';
const today = new Date(); const today = new Date();
@ -18,11 +26,11 @@ export async function render(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <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><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. Device-level schedules override group-level.">?</span></h1><div class="subtitle">Content scheduling calendar</div></div>
</div> </div>
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center"> <div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
<select id="schedDevice" class="input" style="width:200px;background:var(--bg-input)"> <select id="schedDevice" class="input" style="width:200px;background:var(--bg-input)">
${devices.map(d => `<option value="${d.id}">${d.name}</option>`).join('')} ${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
</select> </select>
<button class="btn btn-secondary btn-sm" id="prevWeek">&lt; Prev</button> <button class="btn btn-secondary btn-sm" id="prevWeek">&lt; Prev</button>
<span id="weekLabel" style="color:var(--text-secondary);font-size:13px"></span> <span id="weekLabel" style="color:var(--text-secondary);font-size:13px"></span>
@ -42,9 +50,40 @@ export async function render(container) {
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"><label>Content</label> <div class="form-group"><label>Apply to</label>
<div style="display:flex;gap:16px;margin-bottom:8px">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
<input type="radio" name="schedTarget" value="device" checked id="schedTargetDevice"> Device
</label>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
<input type="radio" name="schedTarget" value="group" id="schedTargetGroup"> Group
</label>
</div>
<select id="schedDeviceSelect" class="input" style="background:var(--bg-input)">
${devices.map(d => `<option value="${esc(d.id)}">${esc(d.name)}</option>`).join('')}
</select>
<select id="schedGroupSelect" class="input" style="background:var(--bg-input);display:none">
${groups.map(g => `<option value="${esc(g.id)}">${esc(g.name)} (${g.device_count} devices)</option>`).join('')}
</select>
${groups.length === 0 ? '<div id="schedNoGroups" style="display:none;color:var(--text-muted);font-size:12px;margin-top:4px">No groups created yet. Create groups in the Displays page.</div>' : ''}
<div id="schedZoneNote" style="display:none;color:var(--text-muted);font-size:11px;margin-top:4px">Note: Zone-based schedules are layout-specific. Ensure all devices in the group use the same layout.</div>
</div>
<div class="form-group"><label>Playlist override</label>
<select id="schedPlaylist" class="input" style="background:var(--bg-input)">
<option value=""> No playlist override </option>
${playlists.map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.status === 'draft' ? ' (draft)' : ''}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>Layout override</label>
<select id="schedLayout" class="input" style="background:var(--bg-input)">
<option value=""> No layout override </option>
${layouts.map(l => `<option value="${esc(l.id)}">${esc(l.name)}</option>`).join('')}
</select>
</div>
<div class="form-group"><label>Content <span style="color:var(--text-muted);font-weight:normal;font-size:11px">(single item, optional)</span></label>
<select id="schedContent" class="input" style="background:var(--bg-input)"> <select id="schedContent" class="input" style="background:var(--bg-input)">
${content.map(c => `<option value="${c.id}">${c.filename}</option>`).join('')} <option value=""> None </option>
${content.map(c => `<option value="${esc(c.id)}">${esc(c.filename)}</option>`).join('')}
</select> </select>
</div> </div>
<div class="form-group"><label>Title (optional)</label><input type="text" id="schedTitle" class="input" placeholder="e.g., Morning Playlist"></div> <div class="form-group"><label>Title (optional)</label><input type="text" id="schedTitle" class="input" placeholder="e.g., Morning Playlist"></div>
@ -75,6 +114,25 @@ export async function render(container) {
let currentWeekStart = new Date(weekStart); let currentWeekStart = new Date(weekStart);
let editingId = null; let editingId = null;
// Wire up target radio buttons
const deviceRadio = document.getElementById('schedTargetDevice');
const groupRadio = document.getElementById('schedTargetGroup');
const deviceSelect = document.getElementById('schedDeviceSelect');
const groupSelect = document.getElementById('schedGroupSelect');
const noGroupsMsg = document.getElementById('schedNoGroups');
const zoneNote = document.getElementById('schedZoneNote');
function updateTargetVisibility() {
const isGroup = groupRadio.checked;
deviceSelect.style.display = isGroup ? 'none' : '';
groupSelect.style.display = isGroup ? '' : 'none';
if (noGroupsMsg) noGroupsMsg.style.display = (isGroup && groups.length === 0) ? '' : 'none';
zoneNote.style.display = isGroup ? '' : 'none';
}
deviceRadio.addEventListener('change', updateTargetVisibility);
groupRadio.addEventListener('change', updateTargetVisibility);
function updateWeekLabel() { function updateWeekLabel() {
const end = new Date(currentWeekStart); const end = new Date(currentWeekStart);
end.setDate(end.getDate() + 6); end.setDate(end.getDate() + 6);
@ -125,12 +183,17 @@ export async function render(container) {
const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`); const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`);
if (!cell) return; if (!cell) return;
const isGroupSchedule = !!ev.group_id;
const block = document.createElement('div'); const block = document.createElement('div');
const topOffset = (startHour - Math.floor(startHour)) * 28; 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; 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`; 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'; ${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.6);' : ''}`;
block.title = `${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}`;
const label = ev.title || ev.playlist_name || ev.content_name || ev.widget_name || 'Scheduled';
const prefix = isGroupSchedule ? `[${esc(ev.group_name || 'Group')}] ` : '';
block.textContent = prefix + label;
block.title = `${isGroupSchedule ? 'Group: ' + (ev.group_name || '') + '\n' : ''}${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}\nPriority: ${ev.priority}`;
block.onclick = () => editSchedule(ev); block.onclick = () => editSchedule(ev);
cell.appendChild(block); cell.appendChild(block);
}); });
@ -139,6 +202,8 @@ export async function render(container) {
function editSchedule(ev) { function editSchedule(ev) {
editingId = ev.id; editingId = ev.id;
document.getElementById('schedModalTitle').textContent = 'Edit Schedule'; document.getElementById('schedModalTitle').textContent = 'Edit Schedule';
document.getElementById('schedPlaylist').value = ev.playlist_id || '';
document.getElementById('schedLayout').value = ev.layout_id || '';
document.getElementById('schedContent').value = ev.content_id || ''; document.getElementById('schedContent').value = ev.content_id || '';
document.getElementById('schedTitle').value = ev.title || ''; document.getElementById('schedTitle').value = ev.title || '';
const start = new Date(ev.start_time); const start = new Date(ev.start_time);
@ -148,6 +213,17 @@ export async function render(container) {
document.getElementById('schedRepeat').value = ev.recurrence || ''; document.getElementById('schedRepeat').value = ev.recurrence || '';
document.getElementById('schedPriority').value = ev.priority || 0; document.getElementById('schedPriority').value = ev.priority || 0;
document.getElementById('schedColor').value = ev.color || '#3B82F6'; document.getElementById('schedColor').value = ev.color || '#3B82F6';
// Set target type
if (ev.group_id) {
groupRadio.checked = true;
groupSelect.value = ev.group_id;
} else {
deviceRadio.checked = true;
deviceSelect.value = ev.device_id || document.getElementById('schedDevice').value;
}
updateTargetVisibility();
document.getElementById('scheduleModal').style.display = 'flex'; document.getElementById('scheduleModal').style.display = 'flex';
} }
@ -155,19 +231,35 @@ export async function render(container) {
editingId = null; editingId = null;
document.getElementById('schedModalTitle').textContent = 'Add Schedule'; document.getElementById('schedModalTitle').textContent = 'Add Schedule';
document.getElementById('schedTitle').value = ''; document.getElementById('schedTitle').value = '';
document.getElementById('schedPlaylist').value = '';
document.getElementById('schedLayout').value = '';
document.getElementById('schedContent').value = '';
// Default to current device in the calendar selector
deviceRadio.checked = true;
deviceSelect.value = document.getElementById('schedDevice').value;
updateTargetVisibility();
document.getElementById('scheduleModal').style.display = 'flex'; document.getElementById('scheduleModal').style.display = 'flex';
}; };
document.getElementById('saveScheduleBtn').onclick = async () => { document.getElementById('saveScheduleBtn').onclick = async () => {
const deviceId = document.getElementById('schedDevice').value; const isGroup = groupRadio.checked;
const contentId = document.getElementById('schedContent').value; const contentId = document.getElementById('schedContent').value;
const startTime = document.getElementById('schedStart').value; const startTime = document.getElementById('schedStart').value;
const endTime = document.getElementById('schedEnd').value; const endTime = document.getElementById('schedEnd').value;
if (isGroup && groups.length === 0) {
showToast('No groups available. Create a group first.', 'error');
return;
}
const playlistId = document.getElementById('schedPlaylist').value;
const layoutId = document.getElementById('schedLayout').value;
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const data = { const data = {
device_id: deviceId, content_id: contentId || null,
content_id: contentId, playlist_id: playlistId || null,
layout_id: layoutId || null,
title: document.getElementById('schedTitle').value, title: document.getElementById('schedTitle').value,
start_time: `${today}T${startTime}:00`, start_time: `${today}T${startTime}:00`,
end_time: `${today}T${endTime}:00`, end_time: `${today}T${endTime}:00`,
@ -176,6 +268,12 @@ export async function render(container) {
color: document.getElementById('schedColor').value, color: document.getElementById('schedColor').value,
}; };
if (isGroup) {
data.group_id = groupSelect.value;
} else {
data.device_id = deviceSelect.value;
}
try { try {
if (editingId) { if (editingId) {
await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) }); await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) });

View file

@ -63,6 +63,8 @@ const migrations = [
// Phase 3: playlist publish/draft state // Phase 3: playlist publish/draft state
"ALTER TABLE playlists ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'", "ALTER TABLE playlists ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'",
"ALTER TABLE playlists ADD COLUMN published_snapshot TEXT", "ALTER TABLE playlists ADD COLUMN published_snapshot TEXT",
// Phase 4: group scheduling (column add only — full migration with CHECK below)
"ALTER TABLE schedules ADD COLUMN group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL",
]; ];
for (const sql of migrations) { for (const sql of migrations) {
try { db.exec(sql); } catch (e) { /* already exists */ } try { db.exec(sql); } catch (e) { /* already exists */ }
@ -244,6 +246,62 @@ function migratePublishSnapshots() {
migratePublishSnapshots(); migratePublishSnapshots();
// Phase 4 migration: add group_id to schedules, make device_id nullable, add CHECK constraint
const PHASE4_MIGRATION_ID = 'phase4_group_schedules';
function migrateGroupSchedules() {
const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE4_MIGRATION_ID);
if (already) return;
console.log('Phase 4 migration: adding group_id to schedules, making device_id nullable...');
const migrate = db.transaction(() => {
db.exec(`
CREATE TABLE schedules_new (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
device_id TEXT REFERENCES devices(id) ON DELETE CASCADE,
group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL,
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,
playlist_id TEXT REFERENCES playlists(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')),
CHECK ((device_id IS NOT NULL AND group_id IS NULL) OR (device_id IS NULL AND group_id IS NOT NULL))
);
INSERT INTO schedules_new (id, user_id, device_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)
SELECT id, user_id, device_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
FROM schedules;
DROP TABLE schedules;
ALTER TABLE schedules_new RENAME TO schedules;
CREATE INDEX idx_schedules_device ON schedules(device_id, enabled);
CREATE INDEX idx_schedules_group ON schedules(group_id, enabled);
`);
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE4_MIGRATION_ID);
console.log('Phase 4 migration complete: schedules table rebuilt with group_id support.');
});
migrate();
}
migrateGroupSchedules();
// Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000) // Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000)
function pruneTelemetry(deviceId) { function pruneTelemetry(deviceId) {
db.prepare(` db.prepare(`

View file

@ -195,7 +195,8 @@ CREATE TABLE IF NOT EXISTS widgets (
CREATE TABLE IF NOT EXISTS schedules ( CREATE TABLE IF NOT EXISTS schedules (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id), user_id TEXT NOT NULL REFERENCES users(id),
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, device_id TEXT REFERENCES devices(id) ON DELETE CASCADE,
group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL,
zone_id TEXT REFERENCES layout_zones(id) ON DELETE CASCADE, zone_id TEXT REFERENCES layout_zones(id) ON DELETE CASCADE,
content_id TEXT REFERENCES content(id) ON DELETE CASCADE, content_id TEXT REFERENCES content(id) ON DELETE CASCADE,
widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE, widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE,
@ -211,10 +212,12 @@ CREATE TABLE IF NOT EXISTS schedules (
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
color TEXT DEFAULT '#3B82F6', color TEXT DEFAULT '#3B82F6',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
CHECK ((device_id IS NOT NULL AND group_id IS NULL) OR (device_id IS NULL AND group_id IS NOT NULL))
); );
CREATE INDEX IF NOT EXISTS idx_schedules_device ON schedules(device_id, enabled); CREATE INDEX IF NOT EXISTS idx_schedules_device ON schedules(device_id, enabled);
CREATE INDEX IF NOT EXISTS idx_schedules_group ON schedules(group_id, enabled);
-- ===================== VIDEO WALLS ===================== -- ===================== VIDEO WALLS =====================

View file

@ -108,13 +108,14 @@
let streamTimer = null; let streamTimer = null;
let layout = null; let layout = null;
let zones = {}; let zones = {};
let userHasInteracted = false; let userHasInteracted = !!localStorage.getItem('rd_audio_unlocked');
let advanceTimer = null; let advanceTimer = null;
// Track user interaction for autoplay policy // Track user interaction for autoplay policy
['click', 'touchstart', 'keydown'].forEach(evt => { ['click', 'touchstart', 'keydown'].forEach(evt => {
document.addEventListener(evt, () => { document.addEventListener(evt, () => {
userHasInteracted = true; userHasInteracted = true;
localStorage.setItem('rd_audio_unlocked', '1');
// Try to unmute any playing HTML5 video // Try to unmute any playing HTML5 video
const video = document.querySelector('#playerContainer video'); const video = document.querySelector('#playerContainer video');
if (video && video.muted) { if (video && video.muted) {
@ -181,6 +182,12 @@
playCurrentItem(); playCurrentItem();
} }
// If audio was previously unlocked (user tapped in a prior session), skip the overlay
if (userHasInteracted) {
console.log('Audio previously unlocked, skipping tap overlay');
unlockAudio();
connect(config.serverUrl);
} else {
// Show tap-to-start overlay to unlock audio on auto-reconnect // Show tap-to-start overlay to unlock audio on auto-reconnect
const tapOverlay = document.createElement('div'); 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.style.cssText = 'position:fixed;inset:0;background:#111827;z-index:2000;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer';
@ -206,6 +213,7 @@
} }
}, 5000); }, 5000);
} }
}
// ==================== Setup UI ==================== // ==================== Setup UI ====================
const savedUrl = config.serverUrl || window.location.origin; const savedUrl = config.serverUrl || window.location.origin;
@ -215,6 +223,7 @@
// Unlock audio on any user interaction // Unlock audio on any user interaction
function unlockAudio() { function unlockAudio() {
userHasInteracted = true; userHasInteracted = true;
localStorage.setItem('rd_audio_unlocked', '1');
// Create and resume AudioContext (unlocks audio for the session) // Create and resume AudioContext (unlocks audio for the session)
try { try {
const ctx = new (window.AudioContext || window.webkitAudioContext)(); const ctx = new (window.AudioContext || window.webkitAudioContext)();

View file

@ -47,10 +47,53 @@ router.put('/:id', requireGroupOwnership, (req, res) => {
res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id)); res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
}); });
// Delete group // Delete group — converts group schedules to per-device schedules first
router.delete('/:id', requireGroupOwnership, (req, res) => { router.delete('/:id', requireGroupOwnership, (req, res) => {
db.prepare('DELETE FROM device_groups WHERE id = ?').run(req.params.id); const groupId = req.params.id;
res.json({ success: true });
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 // Get devices in a group

View file

@ -3,13 +3,49 @@ const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
// Helper: build the expanded schedule query for a device (device-level + group-level)
function getDeviceSchedulesQuery() {
return `
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name,
dg.name as group_name, dg.color as group_color
FROM schedules s
LEFT JOIN content c ON s.content_id = c.id
LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id
LEFT JOIN device_groups dg ON s.group_id = dg.id
WHERE s.enabled = 1
AND (
s.device_id = ?
OR s.group_id IN (
SELECT group_id FROM device_group_members WHERE device_id = ?
)
)
ORDER BY
CASE WHEN s.device_id IS NOT NULL THEN 1 ELSE 0 END DESC,
s.priority DESC,
s.created_at ASC
`;
}
// List schedules (filterable) // List schedules (filterable)
router.get('/', (req, res) => { router.get('/', (req, res) => {
const { device_id, start, end } = req.query; const { device_id, group_id, start, end } = req.query;
let sql = 'SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name FROM schedules s LEFT JOIN content c ON s.content_id = c.id LEFT JOIN widgets w ON s.widget_id = w.id LEFT JOIN playlists p ON s.playlist_id = p.id WHERE s.user_id = ?'; let sql = `SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name,
dg.name as group_name, dg.color as group_color
FROM schedules s
LEFT JOIN content c ON s.content_id = c.id
LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id
LEFT JOIN device_groups dg ON s.group_id = dg.id
WHERE s.user_id = ?`;
const params = [req.user.id]; const params = [req.user.id];
if (device_id) { sql += ' AND s.device_id = ?'; params.push(device_id); } if (device_id) {
// Return both device-level and group-level schedules affecting this device
sql += ` AND (s.device_id = ? OR s.group_id IN (SELECT group_id FROM device_group_members WHERE device_id = ?))`;
params.push(device_id, device_id);
}
if (group_id) { sql += ' AND s.group_id = ?'; params.push(group_id); }
if (start) { sql += ' AND s.end_time >= ?'; params.push(start); } if (start) { sql += ' AND s.end_time >= ?'; params.push(start); }
if (end) { sql += ' AND s.start_time <= ?'; params.push(end); } if (end) { sql += ' AND s.start_time <= ?'; params.push(end); }
@ -23,15 +59,7 @@ router.get('/device/:deviceId', (req, res) => {
if (!device) return res.status(404).json({ error: 'Device not found' }); if (!device) return res.status(404).json({ error: 'Device not found' });
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
const schedules = db.prepare(` const schedules = db.prepare(getDeviceSchedulesQuery()).all(req.params.deviceId, req.params.deviceId);
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name
FROM schedules s
LEFT JOIN content c ON s.content_id = c.id
LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id
WHERE s.device_id = ? AND s.enabled = 1
ORDER BY s.priority DESC, s.start_time ASC
`).all(req.params.deviceId);
res.json(schedules); res.json(schedules);
}); });
@ -51,15 +79,7 @@ router.get('/week', (req, res) => {
const weekEnd = new Date(weekStart); const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 7); weekEnd.setDate(weekEnd.getDate() + 7);
const schedules = db.prepare(` const schedules = db.prepare(getDeviceSchedulesQuery()).all(device_id, device_id);
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name
FROM schedules s
LEFT JOIN content c ON s.content_id = c.id
LEFT JOIN widgets w ON s.widget_id = w.id
LEFT JOIN playlists p ON s.playlist_id = p.id
WHERE s.device_id = ? AND s.enabled = 1
ORDER BY s.priority DESC, s.start_time ASC
`).all(device_id);
const events = []; const events = [];
for (const s of schedules) { for (const s of schedules) {
@ -72,19 +92,43 @@ router.get('/week', (req, res) => {
// Create schedule // Create schedule
router.post('/', (req, res) => { router.post('/', (req, res) => {
const { device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, const { device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time,
timezone, recurrence, recurrence_end, priority, color } = req.body; timezone, recurrence, recurrence_end, priority, color } = req.body;
if (!device_id || !start_time || !end_time) { if (!start_time || !end_time) {
return res.status(400).json({ error: 'device_id, start_time, and end_time required' }); return res.status(400).json({ error: 'start_time and end_time required' });
}
// Mutual exclusion: exactly one of device_id or group_id
if (device_id && group_id) {
return res.status(400).json({ error: 'Cannot set both device_id and group_id. A schedule applies to one device OR one group.' });
}
if (!device_id && !group_id) {
return res.status(400).json({ error: 'Either device_id or group_id is required' });
}
// Ownership checks
if (device_id) {
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 !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
}
if (group_id) {
const group = db.prepare('SELECT user_id FROM device_groups WHERE id = ?').get(group_id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (!['admin','superadmin'].includes(req.user.role) && group.user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
} }
const id = uuidv4(); const id = uuidv4();
db.prepare(` db.prepare(`
INSERT INTO schedules (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, 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, color) start_time, end_time, timezone, recurrence, recurrence_end, priority, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.user.id, device_id, zone_id || null, content_id || null, widget_id || null, `).run(id, req.user.id, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null,
layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || 'UTC', layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || 'UTC',
recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6'); recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6');
@ -98,7 +142,26 @@ router.put('/:id', (req, res) => {
if (!schedule) return res.status(404).json({ error: 'Schedule not found' }); if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
if (!['admin','superadmin'].includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); if (!['admin','superadmin'].includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
const fields = ['device_id', 'zone_id', 'content_id', 'widget_id', 'layout_id', 'playlist_id', 'title', // If changing target, enforce mutual exclusion
const newDeviceId = req.body.device_id !== undefined ? req.body.device_id : schedule.device_id;
const newGroupId = req.body.group_id !== undefined ? req.body.group_id : schedule.group_id;
if (newDeviceId && newGroupId) {
return res.status(400).json({ error: 'Cannot set both device_id and group_id' });
}
if (!newDeviceId && !newGroupId) {
return res.status(400).json({ error: 'Either device_id or group_id is required' });
}
// Ownership check if changing to a new group
if (req.body.group_id && req.body.group_id !== schedule.group_id) {
const group = db.prepare('SELECT user_id FROM device_groups WHERE id = ?').get(req.body.group_id);
if (!group) return res.status(404).json({ error: 'Group not found' });
if (!['admin','superadmin'].includes(req.user.role) && group.user_id !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
}
const fields = ['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']; 'start_time', 'end_time', 'timezone', 'recurrence', 'recurrence_end', 'priority', 'enabled', 'color'];
const updates = []; const updates = [];
const values = []; const values = [];
@ -106,6 +169,14 @@ router.put('/:id', (req, res) => {
if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); } if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); }
}); });
// When switching from device to group (or vice versa), null out the other field
if (req.body.group_id && !updates.some(u => u.startsWith('device_id'))) {
updates.push('device_id = ?'); values.push(null);
}
if (req.body.device_id && !updates.some(u => u.startsWith('group_id'))) {
updates.push('group_id = ?'); values.push(null);
}
if (updates.length > 0) { if (updates.length > 0) {
updates.push("updated_at = strftime('%s','now')"); updates.push("updated_at = strftime('%s','now')");
values.push(req.params.id); values.push(req.params.id);

View file

@ -89,7 +89,7 @@ router.get('/export', (req, res) => {
const playlistPlaceholders = playlistIds.map(() => '?').join(',') || "'__none__'"; const playlistPlaceholders = playlistIds.map(() => '?').join(',') || "'__none__'";
const playlistItems = playlistIds.length ? db.prepare(`SELECT id, playlist_id, content_id, widget_id, sort_order, duration_sec FROM playlist_items WHERE playlist_id IN (${playlistPlaceholders})`).all(...playlistIds) : []; const playlistItems = playlistIds.length ? db.prepare(`SELECT id, playlist_id, content_id, widget_id, sort_order, duration_sec FROM playlist_items WHERE playlist_id IN (${playlistPlaceholders})`).all(...playlistIds) : [];
const schedules = db.prepare('SELECT id, device_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 FROM schedules WHERE user_id = ?').all(userId); const schedules = db.prepare('SELECT 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 FROM schedules WHERE user_id = ?').all(userId);
const videoWalls = db.prepare('SELECT * FROM video_walls WHERE user_id = ?').all(userId); const videoWalls = db.prepare('SELECT * FROM video_walls WHERE user_id = ?').all(userId);
const wallIds = videoWalls.map(w => w.id); const wallIds = videoWalls.map(w => w.id);
const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'"; const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'";
@ -355,11 +355,13 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
// Import schedules // Import schedules
for (const s of (data.schedules || [])) { for (const s of (data.schedules || [])) {
const devId = idMap.devices[s.device_id]; const devId = s.device_id ? (idMap.devices[s.device_id] || null) : null;
if (!devId) continue; const grpId = s.group_id ? (idMap.groups[s.group_id] || null) : null;
// Must have either a mapped device or group target
if (!devId && !grpId) continue;
const newId = uuid.v4(); const newId = uuid.v4();
const playlistId = s.playlist_id ? (idMap.playlists[s.playlist_id] || null) : null; const playlistId = s.playlist_id ? (idMap.playlists[s.playlist_id] || null) : null;
db.prepare(`INSERT INTO schedules (id, user_id, device_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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000)); 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, grpId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000));
stats.schedules++; stats.schedules++;
} }

View file

@ -23,9 +23,18 @@ function evaluateSchedules() {
const schedules = db.prepare(` const schedules = db.prepare(`
SELECT s.* SELECT s.*
FROM schedules s FROM schedules s
WHERE s.device_id = ? AND s.enabled = 1 WHERE s.enabled = 1
ORDER BY s.priority DESC AND (
`).all(device.id); s.device_id = ?
OR s.group_id IN (
SELECT group_id FROM device_group_members WHERE device_id = ?
)
)
ORDER BY
CASE WHEN s.device_id IS NOT NULL THEN 1 ELSE 0 END DESC,
s.priority DESC,
s.created_at ASC
`).all(device.id, device.id);
const active = schedules.find(s => isScheduleActiveNow(s, now)); const active = schedules.find(s => isScheduleActiveNow(s, now));
const override = activeOverrides.get(device.id); const override = activeOverrides.get(device.id);