mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
2104c9cc9f
commit
52dd44a3e8
|
|
@ -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 playlistLabel = getGroupPlaylistLabel(devices, playlists);
|
||||
return `
|
||||
<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;align-items:center;gap:10px">
|
||||
<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' : ''} · ${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 style="display:flex;gap:6px;align-items:center">
|
||||
${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)">
|
||||
<option value="">Send Command...</option>
|
||||
${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;
|
||||
|
||||
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
|
||||
const online = devices.filter(d => d.status === 'online').length;
|
||||
|
|
@ -330,7 +348,7 @@ async function loadDashboard() {
|
|||
|
||||
// Render each group with its devices
|
||||
for (const g of groupsWithDevices) {
|
||||
html += renderGroupSection(g, g.devices);
|
||||
html += renderGroupSection(g, g.devices, playlists);
|
||||
}
|
||||
|
||||
// Render ungrouped devices
|
||||
|
|
@ -358,6 +376,30 @@ async function loadDashboard() {
|
|||
}
|
||||
|
||||
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
|
||||
document.querySelectorAll('.group-cmd-select').forEach(select => {
|
||||
select.addEventListener('change', async (e) => {
|
||||
|
|
|
|||
|
|
@ -6,9 +6,17 @@ const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type':
|
|||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
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) {
|
||||
const devices = await api.getDevices();
|
||||
const content = await api.getContent();
|
||||
const [devices, content, groups, playlists, layoutsRaw] = await Promise.all([
|
||||
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 today = new Date();
|
||||
|
|
@ -18,11 +26,11 @@ export async function render(container) {
|
|||
|
||||
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><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 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('')}
|
||||
${devices.map(d => `<option value="${esc(d.id)}">${esc(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>
|
||||
|
|
@ -42,9 +50,40 @@ export async function render(container) {
|
|||
</button>
|
||||
</div>
|
||||
<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)">
|
||||
${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>
|
||||
</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 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() {
|
||||
const end = new Date(currentWeekStart);
|
||||
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}"]`);
|
||||
if (!cell) return;
|
||||
|
||||
const isGroupSchedule = !!ev.group_id;
|
||||
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()}`;
|
||||
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;
|
||||
${isGroupSchedule ? 'border:1.5px dashed rgba(255,255,255,0.6);' : ''}`;
|
||||
|
||||
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);
|
||||
cell.appendChild(block);
|
||||
});
|
||||
|
|
@ -139,6 +202,8 @@ export async function render(container) {
|
|||
function editSchedule(ev) {
|
||||
editingId = ev.id;
|
||||
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('schedTitle').value = ev.title || '';
|
||||
const start = new Date(ev.start_time);
|
||||
|
|
@ -148,6 +213,17 @@ export async function render(container) {
|
|||
document.getElementById('schedRepeat').value = ev.recurrence || '';
|
||||
document.getElementById('schedPriority').value = ev.priority || 0;
|
||||
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';
|
||||
}
|
||||
|
||||
|
|
@ -155,19 +231,35 @@ export async function render(container) {
|
|||
editingId = null;
|
||||
document.getElementById('schedModalTitle').textContent = 'Add Schedule';
|
||||
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('saveScheduleBtn').onclick = async () => {
|
||||
const deviceId = document.getElementById('schedDevice').value;
|
||||
const isGroup = groupRadio.checked;
|
||||
const contentId = document.getElementById('schedContent').value;
|
||||
const startTime = document.getElementById('schedStart').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 data = {
|
||||
device_id: deviceId,
|
||||
content_id: contentId,
|
||||
content_id: contentId || null,
|
||||
playlist_id: playlistId || null,
|
||||
layout_id: layoutId || null,
|
||||
title: document.getElementById('schedTitle').value,
|
||||
start_time: `${today}T${startTime}:00`,
|
||||
end_time: `${today}T${endTime}:00`,
|
||||
|
|
@ -176,6 +268,12 @@ export async function render(container) {
|
|||
color: document.getElementById('schedColor').value,
|
||||
};
|
||||
|
||||
if (isGroup) {
|
||||
data.group_id = groupSelect.value;
|
||||
} else {
|
||||
data.device_id = deviceSelect.value;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ const migrations = [
|
|||
// Phase 3: playlist publish/draft state
|
||||
"ALTER TABLE playlists ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'",
|
||||
"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) {
|
||||
try { db.exec(sql); } catch (e) { /* already exists */ }
|
||||
|
|
@ -244,6 +246,62 @@ function 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)
|
||||
function pruneTelemetry(deviceId) {
|
||||
db.prepare(`
|
||||
|
|
|
|||
|
|
@ -195,7 +195,8 @@ CREATE TABLE IF NOT EXISTS widgets (
|
|||
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,
|
||||
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,
|
||||
|
|
@ -211,10 +212,12 @@ CREATE TABLE IF NOT EXISTS schedules (
|
|||
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'))
|
||||
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_group ON schedules(group_id, enabled);
|
||||
|
||||
-- ===================== VIDEO WALLS =====================
|
||||
|
||||
|
|
|
|||
|
|
@ -108,13 +108,14 @@
|
|||
let streamTimer = null;
|
||||
let layout = null;
|
||||
let zones = {};
|
||||
let userHasInteracted = false;
|
||||
let userHasInteracted = !!localStorage.getItem('rd_audio_unlocked');
|
||||
let advanceTimer = null;
|
||||
|
||||
// Track user interaction for autoplay policy
|
||||
['click', 'touchstart', 'keydown'].forEach(evt => {
|
||||
document.addEventListener(evt, () => {
|
||||
userHasInteracted = true;
|
||||
localStorage.setItem('rd_audio_unlocked', '1');
|
||||
// Try to unmute any playing HTML5 video
|
||||
const video = document.querySelector('#playerContainer video');
|
||||
if (video && video.muted) {
|
||||
|
|
@ -181,6 +182,12 @@
|
|||
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
|
||||
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';
|
||||
|
|
@ -206,6 +213,7 @@
|
|||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Setup UI ====================
|
||||
const savedUrl = config.serverUrl || window.location.origin;
|
||||
|
|
@ -215,6 +223,7 @@
|
|||
// Unlock audio on any user interaction
|
||||
function unlockAudio() {
|
||||
userHasInteracted = true;
|
||||
localStorage.setItem('rd_audio_unlocked', '1');
|
||||
// Create and resume AudioContext (unlocks audio for the session)
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
|
|
|
|||
|
|
@ -47,10 +47,53 @@ router.put('/:id', requireGroupOwnership, (req, res) => {
|
|||
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) => {
|
||||
db.prepare('DELETE FROM device_groups WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,13 +3,49 @@ const router = express.Router();
|
|||
const { v4: uuidv4 } = require('uuid');
|
||||
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)
|
||||
router.get('/', (req, res) => {
|
||||
const { device_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 = ?';
|
||||
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,
|
||||
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];
|
||||
|
||||
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 (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 (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
|
||||
const schedules = db.prepare(`
|
||||
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);
|
||||
const schedules = db.prepare(getDeviceSchedulesQuery()).all(req.params.deviceId, req.params.deviceId);
|
||||
res.json(schedules);
|
||||
});
|
||||
|
||||
|
|
@ -51,15 +79,7 @@ router.get('/week', (req, res) => {
|
|||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 7);
|
||||
|
||||
const schedules = db.prepare(`
|
||||
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 schedules = db.prepare(getDeviceSchedulesQuery()).all(device_id, device_id);
|
||||
|
||||
const events = [];
|
||||
for (const s of schedules) {
|
||||
|
|
@ -72,19 +92,43 @@ router.get('/week', (req, res) => {
|
|||
|
||||
// Create schedule
|
||||
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;
|
||||
|
||||
if (!device_id || !start_time || !end_time) {
|
||||
return res.status(400).json({ error: 'device_id, start_time, and end_time required' });
|
||||
if (!start_time || !end_time) {
|
||||
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();
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, req.user.id, device_id, zone_id || null, content_id || null, widget_id || null,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).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',
|
||||
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 (!['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'];
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
|
@ -106,6 +169,14 @@ router.put('/:id', (req, res) => {
|
|||
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) {
|
||||
updates.push("updated_at = strftime('%s','now')");
|
||||
values.push(req.params.id);
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ router.get('/export', (req, res) => {
|
|||
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 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 wallIds = videoWalls.map(w => w.id);
|
||||
const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'";
|
||||
|
|
@ -355,11 +355,13 @@ router.post('/import', importUpload.single('file'), async (req, res) => {
|
|||
|
||||
// Import schedules
|
||||
for (const s of (data.schedules || [])) {
|
||||
const devId = idMap.devices[s.device_id];
|
||||
if (!devId) continue;
|
||||
const devId = s.device_id ? (idMap.devices[s.device_id] || null) : null;
|
||||
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 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++;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,18 @@ function evaluateSchedules() {
|
|||
const schedules = db.prepare(`
|
||||
SELECT s.*
|
||||
FROM schedules s
|
||||
WHERE s.device_id = ? AND s.enabled = 1
|
||||
ORDER BY s.priority DESC
|
||||
`).all(device.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
|
||||
`).all(device.id, device.id);
|
||||
|
||||
const active = schedules.find(s => isScheduleActiveNow(s, now));
|
||||
const override = activeOverrides.get(device.id);
|
||||
|
|
|
|||
Loading…
Reference in a new issue