`;
}
}
// #104: draft preview by REUSING the player. Iframes /player in device-free preview
// mode (same-origin -> dashboard CSP frame-src 'self' allows it). The player fetches
// /api/playlists/:id/preview-payload and renders with its unmodified renderer, so the
// preview is byte-identical to what a device shows. Orientation toggle just reloads
// the iframe with &orientation; the server passes it through.
// #238: Portrait here had the same fault as the device preview — the iframe was given the
// as-displayed 9/16 shape AND the player rotated inside it, so the portrait toggle showed sideways
// content. The stage is the panel's face; the iframe is its landscape framebuffer, turned back by
// the stand-in for the wall mount.
function showPlaylistPreview(playlist) {
let orientation = 'landscape';
const aspect = () => displayAspectRatio(orientation);
const frameSrc = () => `/player?preview=1&playlist=${encodeURIComponent(playlist.id)}&orientation=${orientation}&t=${Date.now()}`;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
overlay.innerHTML = `
${t('widget.preview')} — ${esc(playlist.name)}
`;
document.body.appendChild(overlay);
const stage = overlay.querySelector('#pvpStage');
const frame = overlay.querySelector('#pvpFrame');
frameDeviceOutput(stage, frame, orientation);
const btnL = overlay.querySelector('#pvpLandscape');
const btnP = overlay.querySelector('#pvpPortrait');
const btnPrev = overlay.querySelector('#pvpPrev');
const btnNext = overlay.querySelector('#pvpNext');
const position = overlay.querySelector('#pvpPosition');
// #239: skip/next. The preview already IS the real player in device-free preview mode, so the
// control is a message to that one iframe rather than a second copy of the playback logic.
// Addressing frame.contentWindow (not a broadcast) and pinning targetOrigin to our own origin is
// what keeps this off any real screen: a live display holds a socket to the server and is not
// reachable from this page at all, and the preview player itself ignores the message unless it
// booted with ?preview=1.
const send = (action) => {
try { frame.contentWindow?.postMessage({ source: 'screentinker-preview', action }, window.location.origin); } catch (e) {}
};
const onPlayerMessage = (ev) => {
if (ev.origin !== window.location.origin) return;
if (ev.source !== frame.contentWindow) return; // ignore any other frame on the page
const d = ev.data;
if (!d || d.source !== 'screentinker-player' || d.type !== 'preview:state') return;
// A multi-zone playlist plays all zones at once, so there is no single item to step through —
// showing a counter there would be a lie and the buttons would appear dead.
if (d.zoned || !d.total) {
btnPrev.disabled = btnNext.disabled = true;
position.textContent = d.zoned ? t('playlist.preview_zoned') : '';
return;
}
btnPrev.disabled = btnNext.disabled = false;
position.textContent = t('playlist.preview_position', { current: (d.index >= 0 ? d.index : 0) + 1, total: d.total });
};
window.addEventListener('message', onPlayerMessage);
// The player posts its state as soon as it has content, but an orientation reload restarts it —
// ask again on every load so the counter can never be left stale from the previous run.
frame.addEventListener('load', () => send('sync'));
const setOrientation = (o) => {
orientation = o;
// The stage carries the aspect; the frame is rotated inside it (#238).
stage.style.aspectRatio = aspect();
frameDeviceOutput(stage, frame, orientation);
btnPrev.disabled = btnNext.disabled = true; // reloading: no item until the player says so
position.textContent = '';
frame.src = frameSrc();
btnL.className = 'btn btn-sm ' + (o === 'landscape' ? 'btn-primary' : 'btn-secondary');
btnP.className = 'btn btn-sm ' + (o.startsWith('portrait') ? 'btn-primary' : 'btn-secondary');
};
btnL.onclick = () => setOrientation('landscape');
btnP.onclick = () => setOrientation('portrait');
btnPrev.onclick = () => send('prev');
btnNext.onclick = () => send('next');
// Listeners are on window/document, so they outlive the overlay unless close() takes them with
// it — a leaked keydown handler would keep firing at a closed preview.
const close = () => {
overlay.remove();
window.removeEventListener('message', onPlayerMessage);
document.removeEventListener('keydown', onKey);
};
function onKey(ev) {
if (ev.key === 'Escape') close();
else if (ev.key === 'ArrowRight') send('next');
else if (ev.key === 'ArrowLeft') send('prev');
}
overlay.querySelector('#pvpClose').onclick = close;
overlay.onclick = (e) => { if (e.target === overlay) close(); };
document.addEventListener('keydown', onKey);
}
/*
* A small picture of where this playlist's content actually lands.
*
* A playlist has no intrinsic layout — the server derives one from the items' own zone bindings
* (#104) — so the page could previously show an item tagged "Bottom Ticker" with no indication
* that the ticker is a thin strip along the bottom. People assigned content to zones by name and
* found out where it went by looking at a screen.
*
* Drawn from the zone percentages, so it is correct for any layout including portrait ones without
* a stored thumbnail. Zones with no items are dimmed: an empty zone on a real panel shows its
* background colour, and that is worth seeing BEFORE publishing rather than after.
*/
function layoutMockup(playlist) {
const layout = playlist && playlist.layout;
const items = (playlist && playlist.items) || [];
// No layout means fullscreen — every item shares one frame. Drawing a single empty box would
// imply a choice was made; say it in words instead.
if (!layout || !Array.isArray(layout.zones) || layout.zones.length === 0) {
return `
${t('playlist.layout_fullscreen')}
`;
}
const counts = {};
for (const it of items) if (it.zone_id) counts[it.zone_id] = (counts[it.zone_id] || 0) + 1;
const w = Number(layout.width) || 1920;
const h = Number(layout.height) || 1080;
const portrait = h > w;
// Fixed short edge, long edge derived — a portrait mockup must not be as wide as a landscape one
// or it dominates the page.
const boxW = portrait ? 90 : 200;
const boxH = Math.round(boxW * (h / w));
const zones = layout.zones.map((z) => {
const n = counts[z.id] || 0;
const filled = n > 0;
return `
`;
return;
}
list.innerHTML = filtered.map(item => {
const isWidget = activeTab === 'widgets';
const name = item.filename || item.name || t('common.unknown');
// #237: the server gives a video item the clip's own length instead of the 10s default.
// Show that length here so the duration the item lands with is something the operator
// saw coming, rather than a number that appears in the list after the fact.
const clipSec = !isWidget && Number(item.duration_sec) > 0 ? Math.ceil(item.duration_sec) : 0;
const clip = clipSec ? ` · ${Math.floor(clipSec / 60)}:${String(clipSec % 60).padStart(2, '0')}` : '';
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : ((item.mime_type || '') + clip);
const thumb = item.thumbnail_path ? `/api/content/${esc(item.id)}/thumbnail` : null;
return `
${thumb ? `` : ''}
${esc(name)}
${esc(sub)}
`;
}).join('');
hydrateAuthImages(list, { eager: true });
list.querySelectorAll('.add-item-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const type = btn.dataset.type;
const data = type === 'widget' ? { widget_id: id } : { content_id: id };
try {
btn.disabled = true;
if (replaceItemId) {
btn.textContent = t('playlist.replacing');
// PUT supports a content/widget swap; the server nulls the opposite FK and
// preserves duration/schedule/zone. Close on success and re-render the list.
await api.updatePlaylistItem(playlistId, replaceItemId, data);
modal.remove();
const playlist = await api.getPlaylist(playlistId);
renderItems(playlist.items || []);
refreshAfterMutation();
showToast(t('playlist.toast.item_replaced'));
return;
}
btn.textContent = t('playlist.adding');
await api.addPlaylistItem(playlistId, data);
btn.textContent = t('playlist.added');
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
refreshAfterMutation();
} catch (err) {
btn.disabled = false;
btn.textContent = replaceItemId ? t('playlist.replace_btn') : t('playlist.add_btn');
showToast(err.message, 'error');
}
});
});
}
modal.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
activeTab = btn.dataset.tab;
modal.querySelectorAll('.tab-btn').forEach(b => {
b.classList.toggle('btn-primary', b.dataset.tab === activeTab);
b.classList.toggle('btn-secondary', b.dataset.tab !== activeTab);
b.classList.toggle('active', b.dataset.tab === activeTab);
});
renderTab();
});
});
document.getElementById('addItemSearch').addEventListener('input', renderTab);
document.getElementById('closeAddModal').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
renderTab();
}
// #74/#75: per-item schedule editor. Multiple blocks (days + time window + optional
// date range) OR together; an item with no blocks always plays. Client validation
// mirrors the server; saving marks the playlist DRAFT (must re-publish to reach devices).
function showScheduleModal(item) {
let blocks = (item.schedules || []).map(b => ({
days: Array.isArray(b.days) ? [...b.days] : [],
start: b.start || '00:00',
end: b.end || '24:00',
start_date: b.start_date || '',
end_date: b.end_date || ''
}));
const modal = document.createElement('div');
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
document.body.appendChild(modal);
function blockRow(b, idx) {
const eod = b.end === '24:00';
const dayLabels = t('itemsched.dow_short').split(',');
return `