commits
tags
const socket = io();
// --- Fetch-hjälpare med felhantering ---
async function apiFetch(url, options = {}) {
const res = await fetch(url, options);
if (res.redirected && res.url.includes('/login')) {
window.location.href = '/login';
return null;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
}
// --- Visa nu ---
function visaAdress() {
let src = document.getElementById('url-input').value.trim();
if (!src) return;
if (!/^https?:\/\//i.test(src)) src = 'https://' + src;
const embed = youtubeEmbedUrl(src);
if (embed) {
socket.emit('admin_switch', { type: 'url', source: embed });
} else {
socket.emit('admin_switch', { type: 'url', source: src });
}
}
function extractYouTubeId(url) {
const patterns = [
/[?&]v=([a-zA-Z0-9_-]{11})/,
/youtu\.be\/([a-zA-Z0-9_-]{11})/,
/embed\/([a-zA-Z0-9_-]{11})/,
];
for (const p of patterns) {
const m = url.match(p);
if (m) return m[1];
}
return null;
}
function youtubeEmbedUrl(url) {
const id = extractYouTubeId(url);
if (!id) return null;
return `https://www.youtube.com/embed/${id}?autoplay=1`;
}
function extractYouTubePlaylistId(url) {
const m = url.match(/[?&]list=([a-zA-Z0-9_-]+)/);
return m ? m[1] : null;
}
function youtubePlaylistEmbedUrl(url) {
const listId = extractYouTubePlaylistId(url);
if (!listId) return null;
return `https://www.youtube.com/embed?listType=playlist&list=${listId}&autoplay=1&loop=1`;
}
function normalizeMusicYouTubeUrl(url) {
// Convert music.youtube.com or any YouTube playlist URL to an embeddable URL
const listId = extractYouTubePlaylistId(url);
if (!listId) return url;
return `https://www.youtube.com/embed?listType=playlist&list=${listId}&autoplay=1&loop=1&enablejsapi=1`;
}
function visaLokalFil(path, type) {
const src = '/files?path=' + encodeURIComponent(path);
socket.emit('admin_switch', { type, source: src });
}
async function setSchemaCalla(path, type) {
const form = document.getElementById('job-form');
const url = '/files?path=' + encodeURIComponent(path);
form.source.value = url;
form.type.value = type;
if (type === 'video') {
const mins = await getVideoDuration(url);
if (mins !== null) form.duration.value = mins;
}
}
function getVideoDuration(url) {
return new Promise((resolve) => {
const v = document.createElement('video');
v.preload = 'metadata';
v.onloadedmetadata = () => {
const mins = Math.ceil(v.duration / 60 * 100) / 100;
v.src = '';
resolve(mins);
};
v.onerror = () => resolve(null);
v.src = url;
});
}
function laddaUpp() {
const input = document.getElementById('upload-input');
const file = input.files[0];
if (!file) return;
const fill = document.getElementById('progress-fill');
const status = document.getElementById('upload-status');
const bar = document.getElementById('upload-progress');
fill.style.width = '0%';
status.textContent = 'Laddar upp...';
bar.style.display = 'flex';
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
fill.style.width = Math.round(e.loaded / e.total * 100) + '%';
}
};
xhr.onload = () => {
input.value = '';
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
fill.style.width = '100%';
status.textContent = '✓ ' + data.path.split('/').pop();
socket.emit('admin_switch', { type: extType(data.path), source: data.url });
setTimeout(() => { bar.style.display = 'none'; }, 3000);
} else {
status.textContent = 'Fel ' + xhr.status;
}
};
xhr.onerror = () => { status.textContent = 'Nätverksfel'; };
const fd = new FormData();
fd.append('file', file);
xhr.open('POST', '/api/upload');
xhr.send(fd);
}
async function laddaOmDisplay() {
const res = await apiFetch('/api/sysinfo');
if (!res) return;
const d = await res.json();
if (d.active_type && d.active_type !== '—') {
socket.emit('admin_switch', { type: d.active_type, source: d.active_source });
}
}
function tomSkarm() {
socket.emit('admin_switch', { type: 'local', source: '', html: '' });
}
// --- System ---
async function startaOmProgram() {
if (!confirm('Starta om kiosken?')) return;
await apiFetch('/api/restart', { method: 'POST' });
}
async function startaOmSystemet() {
if (!confirm('Starta om hela datorn?')) return;
await apiFetch('/api/reboot', { method: 'POST' });
}
async function stangAvDatorn() {
if (!confirm('Stäng av datorn?')) return;
await apiFetch('/api/shutdown', { method: 'POST' });
}
// --- Schema ---
async function laddaJobb() {
const res = await apiFetch('/api/jobs');
if (!res) return;
const jobs = await res.json();
const tbody = document.getElementById('jobs-body');
if (jobs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty">Inga schemalagda jobb</td></tr>';
return;
}
tbody.innerHTML = jobs.map(j => `
<tr id="job-${j.id}" data-id="${j.id}">
<td>${escHtml(j.label)}</td>
<td>${fmtDuration(j.duration)}</td>
<td>${escHtml(j.type)}</td>
<td class="source-cell">${escHtml(j.source)}</td>
<td>${j.show_chat ? '✓' : '—'}</td>
<td>
<button class="toggle ${j.enabled ? 'on' : 'off'}" onclick="toggleJobb(${j.id})">
${j.enabled ? 'Ja' : 'Nej'}
</button>
</td>
<td>
<button class="secondary" onclick="openEdit(${j.id})">Redigera</button>
<button class="secondary" onclick="moveJob(${j.id}, 'up')">▲</button>
<button class="secondary" onclick="moveJob(${j.id}, 'down')">▼</button>
<button class="danger" onclick="taBortJobb(${j.id})">X</button>
</td>
</tr>
`).join('');
// enable drag-and-drop ordering
makeRowsDraggable(tbody);
}
function makeRowsDraggable(tbody) {
let dragSrc = null;
const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
rows.forEach(row => {
row.draggable = true;
row.addEventListener('dragstart', (e) => {
dragSrc = row;
e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
row.addEventListener('drop', (e) => {
e.preventDefault();
if (!dragSrc || dragSrc === row) return;
const nodes = Array.from(tbody.querySelectorAll('tr[data-id]'));
const srcIdx = nodes.indexOf(dragSrc);
const dstIdx = nodes.indexOf(row);
if (srcIdx < dstIdx) tbody.insertBefore(dragSrc, row.nextSibling);
else tbody.insertBefore(dragSrc, row);
sendReorder(tbody);
});
row.addEventListener('dragend', () => { dragSrc = null; });
});
}
function sendReorder(tbody) {
const newOrder = Array.from(tbody.querySelectorAll('tr[data-id]')).map(r => parseInt(r.dataset.id, 10));
fetch('/api/jobs/reorder', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: newOrder })
}).then(r => { if (r.ok) laddaJobb(); });
}
function _jobTypeChanged(formId, wrapId, statusId, emuWrapId) {
const form = document.getElementById(formId);
const wrap = document.getElementById(wrapId);
const emuWrap = emuWrapId ? document.getElementById(emuWrapId) : null;
if (!form) return;
const type = form.type.value;
const isPlaylist = type === 'youtube_playlist';
const isEmulator = type === 'emulator';
// Show/hide shuffle row for playlist type
const shuffleWrapId = formId === 'job-form' ? 'job-shuffle-wrap' : 'edit-shuffle-wrap';
const shuffleWrap = document.getElementById(shuffleWrapId);
if (shuffleWrap) shuffleWrap.style.display = isPlaylist ? 'block' : 'none';
if (wrap) wrap.style.display = isPlaylist ? 'flex' : 'none';
if (emuWrap) emuWrap.style.display = isEmulator ? 'flex' : 'none';
if (!isPlaylist && statusId) {
const s = document.getElementById(statusId);
if (s) s.textContent = '';
}
}
// Öppnar ROM-bläddraren och fyller source-fältet i det angivna formuläret
function openEmuBrowser(formId) {
const form = document.getElementById(formId);
const nameEl = document.getElementById(
formId === 'job-form' ? 'job-emu-name' : 'edit-emu-name'
);
openBrowser(function (path, _type) {
form.source.value = path;
if (nameEl) nameEl.textContent = path.split('/').pop();
});
}
async function extractJobPlaylist(formId) {
const form = document.getElementById(formId);
const isAdd = formId === 'job-form';
const statusId = isAdd ? 'job-extract-status' : 'edit-extract-status';
const status = document.getElementById(statusId);
const url = form.source.value.trim();
if (!url) { status.textContent = 'Ange URL först.'; return; }
status.style.color = '#444';
status.textContent = 'Hämtar spellista…';
try {
const res = await apiFetch('/api/playlist/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const d = await res.json();
if (!res.ok) {
status.textContent = '⚠ ' + (d.error || 'Okänt fel');
} else {
const totalMin = d.total_seconds ? (d.total_seconds / 60) : null;
// Auto-fyll duration om fältet är tomt
if (totalMin && !form.duration.value) {
form.duration.value = Math.round(totalMin * 100) / 100;
}
const timeStr = totalMin ? _fmtDuration(d.total_seconds) : '';
status.style.color = '#166534';
status.textContent = `✓ ${d.count} videos hämtade${timeStr ? ' · total speltid ' + timeStr : ''}.`;
}
} catch (err) {
status.textContent = '⚠ ' + err.message;
}
}
function _fmtDuration(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
async function laggTillJobb(e) {
e.preventDefault();
const form = e.target;
let type = form.type.value;
let source = form.source.value;
if (type === 'youtube') {
const embed = youtubeEmbedUrl(source);
if (!embed) { alert('Ogiltig YouTube-URL'); return; }
type = 'url';
source = embed;
} else if (type === 'youtube_playlist') {
if (!extractYouTubePlaylistId(source)) { alert('Ogiltig YouTube-spellisteURL (måste innehålla ?list=...)'); return; }
// Behåll type=youtube_playlist och rå URL — display cyclar via IFrame API
} else if (type === 'kiosk_chat') {
source = '/chat-display';
}
const txDur = form.transition_duration ? parseInt(form.transition_duration.value, 10) : 0;
const data = {
label: form.label.value,
duration: form.duration.value,
type,
source,
show_chat: form.show_chat ? form.show_chat.checked : false,
shuffle: form.shuffle ? form.shuffle.checked : false,
transition_type: form.transition_type ? form.transition_type.value : '',
transition_duration: isNaN(txDur) ? 0 : txDur,
};
const res = await apiFetch('/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res) return;
form.reset();
laddaJobb();
}
// --- Edit job ---
function openEdit(id) {
const row = document.getElementById('job-' + id);
if (!row) return;
// fetch job data from server (or parse from row)
fetch('/api/jobs').then(r => r.json()).then(jobs => {
const job = jobs.find(x => x.id === id);
if (!job) return;
const modal = document.getElementById('edit-modal');
const form = document.getElementById('edit-form');
form.elements.namedItem('id').value = job.id;
form.label.value = job.label;
form.duration.value = job.duration;
form.type.value = job.type;
form.source.value = job.source;
if (form.shuffle) form.shuffle.checked = !!job.shuffle;
if (form.transition_type) form.transition_type.value = job.transition_type || '';
if (form.transition_duration) form.transition_duration.value = job.transition_duration || '';
_jobTypeChanged('edit-form', 'edit-extract-wrap', 'edit-extract-status');
modal.style.display = 'flex';
});
}
function closeEditModal() {
document.getElementById('edit-modal').style.display = 'none';
}
function editModalClick(e) {
if (e.target === document.getElementById('edit-modal')) closeEditModal();
}
async function saveEdit(e) {
e.preventDefault();
const form = e.target;
const id = parseInt(form.id.value, 10);
const txDur = form.transition_duration ? parseInt(form.transition_duration.value, 10) : 0;
const payload = {
label: form.label.value,
duration: parseFloat(form.duration.value),
type: form.type.value,
source: form.source.value,
shuffle: form.shuffle ? form.shuffle.checked : false,
transition_type: form.transition_type ? form.transition_type.value : '',
transition_duration: isNaN(txDur) ? 0 : txDur,
};
const res = await apiFetch('/api/jobs/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res) return;
closeEditModal();
laddaJobb();
}
// --- Reorder ---
function moveJob(id, dir) {
const tbody = document.getElementById('jobs-body');
const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
const idx = rows.findIndex(r => String(r.dataset.id) === String(id));
if (idx === -1) return;
let newIdx = dir === 'up' ? idx - 1 : idx + 1;
if (newIdx < 0 || newIdx >= rows.length) return;
// swap nodes
const row = rows[idx];
const ref = rows[newIdx];
if (dir === 'up') tbody.insertBefore(row, ref);
else tbody.insertBefore(ref, row);
// send new order to server
const newOrder = Array.from(tbody.querySelectorAll('tr[data-id]')).map(r => parseInt(r.dataset.id, 10));
fetch('/api/jobs/reorder', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: newOrder })
}).then(r => { if (r.ok) laddaJobb(); });
}
async function taBortJobb(id) {
const res = await apiFetch('/api/jobs/' + id, { method: 'DELETE' });
if (!res) return;
laddaJobb();
}
async function toggleJobb(id) {
const res = await apiFetch('/api/jobs/' + id, { method: 'PATCH' });
if (!res) return;
laddaJobb();
}
// --- Inställningar ---
async function laddaVolym() {
const res = await apiFetch('/api/volume');
if (!res) return;
const d = await res.json();
const slider = document.getElementById('setting-volume');
slider.value = d.volume;
document.getElementById('volume-val').textContent = d.volume + '%';
}
async function setVolym() {
const vol = parseInt(document.getElementById('setting-volume').value, 10);
await apiFetch('/api/volume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ volume: vol }),
});
}
async function laddaInstallningar() {
const [setRes, audioRes, dispRes] = await Promise.all([
apiFetch('/api/settings'),
apiFetch('/api/audio/profiles'),
apiFetch('/api/displays'),
]);
if (!setRes || !audioRes || !dispRes) return;
const settings = await setRes.json();
const profiles = await audioRes.json();
const displays = await dispRes.json();
const audioSel = document.getElementById('setting-audio');
audioSel.innerHTML = profiles.map(p => {
const label = escHtml(p.description || p.name) + (p.available === false ? ' (frånkopplad)' : '');
return `<option value="${p.index}"${p.index === settings.audio_profile_index ? ' selected' : ''}>${label}</option>`;
}).join('');
document.getElementById('setting-internal-display').checked = settings.internal_display !== 'off';
// populate display target select with available outputs + special choices
const dispSel = document.getElementById('setting-display');
const outOptions = [{val:'last',label:'Sista anslutna (standard)'},{val:'first',label:'Första anslutna'}];
displays.forEach(d => outOptions.push({val:d.name,label:d.name + (d.connected ? ' (ansluten)' : ' (ej ansluten)')}));
dispSel.innerHTML = outOptions.map(o => `<option value="${o.val}"${o.val===settings.display_output? ' selected':''}>${escHtml(o.label)}</option>`).join('');
// build a mapping name -> modes/current
const modesByOutput = {};
displays.forEach(d => { modesByOutput[d.name] = {modes: Array.isArray(d.modes)?d.modes:[], current: d.current_mode || ''}; });
const modeSel = document.getElementById('setting-display-mode');
const customInput = document.getElementById('setting-display-mode-custom');
function formatLabel(token) {
// token like '1920x1080@60.000000' -> '1920x1080 @ 60 Hz'
if (!token) return '';
if (token === '__custom__') return 'Ange manuellt...';
const parts = token.split('@');
const hz = parts[1] ? String(Math.round(parseFloat(parts[1])*1000)/1000).replace(/\.0+$/,'') : '';
return parts[0] + (hz ? (' @ ' + hz + ' Hz') : '');
}
function populateModesForOutput(outName) {
const info = modesByOutput[outName] || {modes:[], current:''};
const modes = info.modes.slice();
const currentMode = settings.display_mode || info.current || '';
if (currentMode && !modes.includes(currentMode)) modes.unshift(currentMode);
// sort by resolution descending (compare token prefix)
modes.sort((a,b)=>{const A=a.split('@')[0].split('x').map(Number),B=b.split('@')[0].split('x').map(Number);return B[0]-A[0]||B[1]-A[1];});
modeSel.innerHTML = '<option value="">(Ingen / lämna tomt)</option>' + modes.map(m => `<option value="${m}">${formatLabel(m)}${m===info.current? ' (aktuell)':''}</option>`).join('') + `<option value="__custom__">${formatLabel('__custom__')}</option>`;
// set selected
if (currentMode) {
if (modes.includes(currentMode)) { modeSel.value = currentMode; customInput.style.display='none'; }
else { modeSel.value='__custom__'; customInput.style.display=''; customInput.value = currentMode; }
} else { modeSel.value = ''; customInput.style.display='none'; }
}
// initial populate for selected display
const initialOut = dispSel.value && dispSel.value !== 'last' && dispSel.value !== 'first' ? dispSel.value : (displays.find(d=>d.connected)?.name || '');
if (initialOut) populateModesForOutput(initialOut);
dispSel.addEventListener('change', () => {
const v = dispSel.value;
if (v === 'last' || v === 'first') {
// choose connected first/last
const candidate = v === 'last' ? (displays.slice().reverse().find(d=>d.connected) || displays.find(d=>d.connected)) : (displays.find(d=>d.connected) || null);
populateModesForOutput(candidate ? candidate.name : '');
} else {
populateModesForOutput(v);
}
});
modeSel.addEventListener('change', () => {
if (modeSel.value === '__custom__') { customInput.style.display=''; customInput.focus(); } else { customInput.style.display='none'; }
});
const connected = displays.filter(d => d.connected).map(d => d.name).join(', ');
const disconnected = displays.filter(d => !d.connected).map(d => d.name).join(', ');
const parts = [];
if (connected) parts.push('Anslutna: ' + connected);
if (disconnected) parts.push('Ej anslutna: ' + disconnected);
document.getElementById('displays-info').textContent = parts.join(' | ');
}
async function setInternDisplay(enabled) {
const status = document.getElementById('settings-status');
status.textContent = enabled ? 'Aktiverar intern display...' : 'Inaktiverar intern display...';
const res = await apiFetch('/api/internal_display', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!res) return;
status.textContent = enabled ? '✓ Intern display aktiverad' : '✓ Intern display inaktiverad';
setTimeout(() => { status.textContent = ''; }, 3000);
}
async function sparaInstallningar() {
const profileIndex = parseInt(document.getElementById('setting-audio').value, 10);
const display = document.getElementById('setting-display').value;
const modeSelEl = document.getElementById('setting-display-mode');
let displayMode = '';
if (modeSelEl.value === '__custom__') {
displayMode = document.getElementById('setting-display-mode-custom').value.trim();
} else {
displayMode = modeSelEl.value.trim();
}
const status = document.getElementById('settings-status');
status.textContent = 'Tillämpar...';
const res = await apiFetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audio_profile_index: profileIndex, display_output: display, display_mode: displayMode, internal_display: document.getElementById('setting-internal-display').checked }),
});
if (!res) return;
status.textContent = '✓ Tillämpat';
setTimeout(() => { status.textContent = ''; }, 3000);
}
async function bytaLösenord() {
const current = document.getElementById('cred-current').value;
const user = document.getElementById('cred-user').value.trim();
const pass = document.getElementById('cred-pass').value;
const status = document.getElementById('cred-status');
if (!current || !user || !pass) { status.textContent = 'Fyll i alla fält'; return; }
const res = await apiFetch('/api/settings/credentials', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: current, new_username: user, new_password: pass }),
}).catch(() => null);
if (!res) { status.textContent = '✗ Fel'; return; }
const d = await res.json();
if (d.ok) {
status.textContent = '✓ Sparat — logga in igen';
document.getElementById('cred-current').value = '';
document.getElementById('cred-pass').value = '';
} else {
status.textContent = '✗ ' + (d.error || 'Fel');
}
}
async function applyDisplayNow() {
const modeSelEl = document.getElementById('setting-display-mode');
let displayMode = '';
if (modeSelEl.value === '__custom__') {
displayMode = document.getElementById('setting-display-mode-custom').value.trim();
} else {
displayMode = modeSelEl.value.trim();
}
if (!displayMode) { alert('Ange en upplösning först.'); return; }
const res = await apiFetch('/api/settings/apply_display', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_mode: displayMode, display_output: document.getElementById('setting-display').value })
}).catch(() => null);
if (!res) { alert('Misslyckades att kontakta servern'); return; }
const j = await res.json();
if (j.ok) alert('Upplösning tillämpad (försök att ändra skärm).');
else alert('Kunde inte tillämpa upplösning: ' + (j.error || 'okänt fel'));
}
// --- Kiosk Livechat overlay ---
async function laddaChat() {
const res = await apiFetch('/api/chat');
if (!res) return;
const d = await res.json();
const kw = d.kiosk_width_pct != null ? d.kiosk_width_pct : 30;
const kh = d.kiosk_height != null ? d.kiosk_height : 80;
if (document.getElementById('kiosk-chat-enabled'))
document.getElementById('kiosk-chat-enabled').checked = !!d.kiosk_enabled;
if (document.getElementById('kiosk-chat-position'))
document.getElementById('kiosk-chat-position').value = d.kiosk_position || 'right';
if (document.getElementById('kiosk-chat-width')) {
document.getElementById('kiosk-chat-width').value = kw;
document.getElementById('kiosk-chat-width-val').textContent = kw + '%';
}
if (document.getElementById('kiosk-chat-height')) {
document.getElementById('kiosk-chat-height').value = kh;
document.getElementById('kiosk-chat-height-val').textContent = kh + '%';
}
if (document.getElementById('kiosk-chat-status'))
document.getElementById('kiosk-chat-status').textContent = d.kiosk_enabled ? '● Aktiv overlay' : '';
}
async function _sendKioskChat(kioskEnabled) {
const res = await apiFetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kiosk_enabled: kioskEnabled,
kiosk_position: document.getElementById('kiosk-chat-position').value,
kiosk_width_pct: parseInt(document.getElementById('kiosk-chat-width').value, 10),
kiosk_height: parseInt(document.getElementById('kiosk-chat-height').value, 10),
}),
});
if (!res) return;
if (document.getElementById('kiosk-chat-status'))
document.getElementById('kiosk-chat-status').textContent = kioskEnabled ? '● Aktiv overlay' : '';
if (document.getElementById('kiosk-chat-enabled'))
document.getElementById('kiosk-chat-enabled').checked = kioskEnabled;
}
// --- YouTube Live Chat overlay ---
async function laddaYtChat() {
const res = await apiFetch('/api/yt_chat');
if (!res) return;
const d = await res.json();
if (document.getElementById('yt-chat-position'))
document.getElementById('yt-chat-position').value = d.position || 'right';
if (document.getElementById('yt-chat-bg-color'))
document.getElementById('yt-chat-bg-color').value = d.bg_color || '#000000';
const wp = d.width_pct != null ? d.width_pct : 25;
const h = d.height != null ? d.height : 100;
const bo = d.bg_opacity != null ? d.bg_opacity : 70;
if (document.getElementById('yt-chat-width')) {
document.getElementById('yt-chat-width').value = wp;
document.getElementById('yt-chat-width-val').textContent = wp + '%';
}
if (document.getElementById('yt-chat-height')) {
document.getElementById('yt-chat-height').value = h;
document.getElementById('yt-chat-height-val').textContent = h + '%';
}
if (document.getElementById('yt-chat-bg-opacity')) {
document.getElementById('yt-chat-bg-opacity').value = bo;
document.getElementById('yt-chat-bg-opacity-val').textContent = bo + '%';
}
if (document.getElementById('yt-chat-status'))
document.getElementById('yt-chat-status').textContent = d.enabled ? '● Aktiv' : '';
// Chat theme button
try {
const KEY = 'kiosk_chat_theme';
const btn = document.getElementById('yt-chat-theme-btn');
if (btn) {
const getTheme = () => localStorage.getItem(KEY) || 'auto';
const order = ['auto','dark','light'];
const updateBtn = () => {
const t = getTheme();
btn.textContent = t === 'auto' ? 'Chatt: Auto' : (t === 'dark' ? 'Chatt: Mörk' : 'Chatt: Ljus');
};
btn.onclick = () => {
const next = order[(order.indexOf(getTheme()) + 1) % order.length];
localStorage.setItem(KEY, next);
updateBtn();
window.dispatchEvent(new Event('kiosk-chat-theme-changed'));
};
updateBtn();
window.addEventListener('storage', (e) => { if (e.key === KEY) updateBtn(); });
}
} catch (e) {}
}
async function _sendYtChat(enabled) {
const res = await apiFetch('/api/yt_chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled,
position: document.getElementById('yt-chat-position') ? document.getElementById('yt-chat-position').value : 'right',
width_pct: document.getElementById('yt-chat-width') ? parseInt(document.getElementById('yt-chat-width').value, 10) : 25,
height: document.getElementById('yt-chat-height') ? parseInt(document.getElementById('yt-chat-height').value, 10) : 100,
bg_color: document.getElementById('yt-chat-bg-color') ? document.getElementById('yt-chat-bg-color').value : '#000000',
bg_opacity: document.getElementById('yt-chat-bg-opacity') ? parseInt(document.getElementById('yt-chat-bg-opacity').value, 10) : 70,
}),
});
if (!res) return;
if (document.getElementById('yt-chat-status'))
document.getElementById('yt-chat-status').textContent = enabled ? '● Aktiv' : '';
}
function aktiveraChatOverlay(which) {
if (which === 'yt') _sendYtChat(true);
if (which === 'kiosk') _sendKioskChat(true);
}
function avaktiveraChatOverlay(which) {
if (which === 'yt') _sendYtChat(false);
if (which === 'kiosk') _sendKioskChat(false);
}
// --- Ticker ---
function tickerModeChanged() {
const continuous = document.getElementById('ticker-mode').value === 'continuous';
document.getElementById('ticker-sep-label').style.display = continuous ? '' : 'none';
document.getElementById('ticker-sep-wrap').style.display = continuous ? '' : 'none';
}
function tickerSourceChanged() {
const rss = document.getElementById('ticker-source').value === 'rss';
document.getElementById('ticker-text-label').style.display = rss ? 'none' : '';
document.getElementById('ticker-text-wrap').style.display = rss ? 'none' : '';
document.getElementById('ticker-rss-label').style.display = rss ? '' : 'none';
document.getElementById('ticker-rss-wrap').style.display = rss ? '' : 'none';
document.getElementById('ticker-rss-interval-label').style.display = rss ? '' : 'none';
document.getElementById('ticker-rss-interval').style.display = rss ? '' : 'none';
}
async function laddaTicker() {
const res = await apiFetch('/api/ticker');
if (!res) return;
const d = await res.json();
document.getElementById('ticker-source').value = d.source || 'text';
document.getElementById('ticker-text').value = d.text || '';
document.getElementById('ticker-rss-url').value = d.rss_url || '';
document.getElementById('ticker-rss-interval').value = String(d.rss_interval || 10);
document.getElementById('ticker-mode').value = d.mode || 'sequential';
document.getElementById('ticker-separator').value = d.separator || '◆';
document.getElementById('ticker-position').value = d.position || 'bottom';
document.getElementById('ticker-text-color').value = d.text_color || '#ffffff';
document.getElementById('ticker-font-family').value = d.font_family || 'sans-serif';
document.getElementById('ticker-bar-color').value = d.bar_color || '#000000';
tickerSourceChanged();
tickerModeChanged();
const pps = d.pps != null ? d.pps : 150;
const size = d.font_size != null ? d.font_size : 2;
const opacity = d.bar_opacity != null ? d.bar_opacity : 72;
const padding = d.bar_padding != null ? d.bar_padding : 0.45;
document.getElementById('ticker-pps').value = pps;
document.getElementById('ticker-pps-val').textContent = pps + ' px/s';
document.getElementById('ticker-font-size').value = size;
document.getElementById('ticker-font-size-val').textContent = parseFloat(size).toFixed(1) + 'rem';
document.getElementById('ticker-bar-opacity').value = opacity;
document.getElementById('ticker-bar-opacity-val').textContent = opacity + '%';
document.getElementById('ticker-bar-padding').value = padding;
document.getElementById('ticker-bar-padding-val').textContent = parseFloat(padding).toFixed(2) + 'rem';
document.getElementById('ticker-status').textContent = d.enabled ? '● Aktiv' : '';
}
async function _sendTicker(enabled) {
const res = await apiFetch('/api/ticker', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled,
source: document.getElementById('ticker-source').value,
text: document.getElementById('ticker-text').value,
rss_url: document.getElementById('ticker-rss-url').value,
rss_interval: parseInt(document.getElementById('ticker-rss-interval').value, 10),
mode: document.getElementById('ticker-mode').value,
separator: document.getElementById('ticker-separator').value,
position: document.getElementById('ticker-position').value,
pps: parseInt(document.getElementById('ticker-pps').value, 10),
text_color: document.getElementById('ticker-text-color').value,
font_size: parseFloat(document.getElementById('ticker-font-size').value),
font_family: document.getElementById('ticker-font-family').value,
bar_color: document.getElementById('ticker-bar-color').value,
bar_opacity: parseInt(document.getElementById('ticker-bar-opacity').value, 10),
bar_padding: parseFloat(document.getElementById('ticker-bar-padding').value),
}),
});
if (!res) return;
document.getElementById('ticker-status').textContent = enabled ? '● Aktiv' : '';
}
function aktiveraTicker() { _sendTicker(true); }
function avaktriveraTicker() { _sendTicker(false); }
// BBCode insert helpers
function insertBB(tag, openVal) {
const input = document.getElementById('ticker-text');
const start = input.selectionStart;
const end = input.selectionEnd;
const sel = input.value.slice(start, end);
const open = openVal ? `[${tag}=${openVal}]` : `[${tag}]`;
const close = `[/${tag}]`;
input.value = input.value.slice(0, start) + open + sel + close + input.value.slice(end);
input.focus();
const cur = start + open.length;
input.setSelectionRange(cur, cur + sel.length);
}
function insertBBColor() {
const col = document.getElementById('bb-color-val').value;
insertBB('color', col);
}
function insertBBSize() {
const px = parseInt(document.getElementById('bb-size-val').value, 10) || 48;
insertBB('size', px);
}
function insertBBGlow() {
const col = document.getElementById('bb-color-val').value;
insertBB('glow', col);
}
// --- Wi-Fi ---
let _wifiConnectSsid = '';
let _wifiConnectOpen = false;
function _signalColor(pct) {
if (pct >= 70) return '#22c55e';
if (pct >= 40) return '#f59e0b';
return '#ef4444';
}
function _signalBarsHtml(pct) {
const c = _signalColor(pct);
const lit = pct >= 70 ? 4 : pct >= 50 ? 3 : pct >= 30 ? 2 : 1;
const bars = [4, 7, 11, 14].map((h, i) =>
`<span style="width:4px;border-radius:1px;height:${h}px;background:${i < lit ? c : '#333'}"></span>`
).join('');
return `<div style="display:flex;align-items:flex-end;gap:2px;height:14px">${bars}</div>`;
}
async function laddaWifiStatus() {
try {
const res = await apiFetch('/api/wifi/status');
if (!res) return;
const d = await res.json();
const dot = document.getElementById('wifi-status-dot');
const txt = document.getElementById('wifi-status-text');
const ip = document.getElementById('wifi-ip');
const sig = document.getElementById('wifi-signal-bar');
const sigv = document.getElementById('wifi-signal-val');
if (d.connected) {
dot.style.background = '#22c55e';
txt.textContent = d.ssid || 'Ansluten';
} else {
dot.style.background = '#ef4444';
txt.textContent = 'Ej ansluten';
}
ip.textContent = d.ip || '—';
const pct = d.connected ? Math.max(0, Math.min(100, 2 * (d.signal + 100))) : 0;
sig.innerHTML = _signalBarsHtml(pct);
sigv.textContent = d.connected ? pct + '%' : '—';
if (d.sudo_ok === false) {
document.getElementById('wifi-scan-status').textContent =
'⚠ sudo saknas — se installation nedan';
}
} catch {}
}
async function laddaWifiSaved() {
try {
const res = await apiFetch('/api/wifi/saved');
if (!res) return;
const d = await res.json();
const list = document.getElementById('wifi-saved-list');
if (!d.saved || !d.saved.length) {
list.innerHTML = '<span style="color:#555;font-style:italic">Inga sparade nätverk</span>';
return;
}
list.innerHTML = d.saved.map(n => `
<div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--surface);border-radius:6px">
<span style="flex:1">${escHtml(n.ssid)}${n.current ? ' <span style="color:#22c55e;font-size:.75rem">● aktiv</span>' : ''}</span>
<button class="secondary" style="padding:.2rem .6rem;font-size:.8rem" onclick="wifiRemove('${escHtml(n.id)}','${escHtml(n.ssid)}')">Ta bort</button>
</div>`).join('');
} catch {}
}
async function wifiScan() {
const btn = document.getElementById('wifi-scan-btn');
const status = document.getElementById('wifi-scan-status');
const wrap = document.getElementById('wifi-networks');
const list = document.getElementById('wifi-network-list');
btn.disabled = true;
status.textContent = 'Skannar...';
list.innerHTML = '<span style="color:#555;font-style:italic">Söker...</span>';
wrap.style.display = 'block';
try {
const res = await apiFetch('/api/wifi/networks');
if (!res) { btn.disabled = false; status.textContent = ''; return; }
const d = await res.json();
if (!d.networks || !d.networks.length) {
list.innerHTML = '<span style="color:#555;font-style:italic">Inga nätverk hittades</span>';
} else {
list.innerHTML = d.networks.map(n => {
const lock = n.security !== 'open'
? `<span style="font-size:.75rem;color:#aaa">${escHtml(n.security)}</span>` : '';
return `
<div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--surface);border-radius:6px">
${_signalBarsHtml(n.signal)}
<span style="flex:1">${escHtml(n.ssid)}${n.known ? ' <span style="color:#22c55e;font-size:.75rem">✓</span>' : ''}</span>
${lock}
<button class="secondary" style="padding:.2rem .6rem;font-size:.8rem"
onclick="wifiOpenModal('${escHtml(n.ssid)}',${n.security === 'open'})">
Anslut
</button>
</div>`;
}).join('');
}
status.textContent = d.networks.length + ' nätverk';
} catch (e) {
status.textContent = 'Fel: ' + e.message;
list.innerHTML = '';
}
btn.disabled = false;
}
function wifiOpenModal(ssid, isOpen) {
_wifiConnectSsid = ssid;
_wifiConnectOpen = isOpen;
document.getElementById('wifi-pw-title').textContent = 'Anslut till ' + ssid;
document.getElementById('wifi-pw-input').value = '';
document.getElementById('wifi-pw-input').type = isOpen ? 'text' : 'password';
document.getElementById('wifi-pw-input').placeholder = isOpen ? 'Öppet nätverk — inget lösenord behövs' : 'Wi-Fi lösenord';
document.getElementById('wifi-connect-status').textContent = '';
document.getElementById('wifi-connect-btn').disabled = false;
document.getElementById('wifi-pw-modal').style.display = 'flex';
if (!isOpen) document.getElementById('wifi-pw-input').focus();
}
function closeWifiModal() {
document.getElementById('wifi-pw-modal').style.display = 'none';
}
function wifiModalClick(e) {
if (e.target === document.getElementById('wifi-pw-modal')) closeWifiModal();
}
async function wifiDoConnect() {
const btn = document.getElementById('wifi-connect-btn');
const status = document.getElementById('wifi-connect-status');
const pw = document.getElementById('wifi-pw-input').value;
btn.disabled = true;
status.textContent = 'Ansluter... (upp till 20 s)';
try {
const res = await apiFetch('/api/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: _wifiConnectSsid, password: pw }),
});
if (!res) { btn.disabled = false; return; }
const d = await res.json();
if (d.ok) {
status.textContent = '✓ Ansluten — IP: ' + (d.ip || '—');
setTimeout(() => { closeWifiModal(); laddaWifiStatus(); laddaWifiSaved(); }, 1500);
} else {
status.textContent = '✗ ' + (d.message || 'Misslyckades');
btn.disabled = false;
}
} catch (e) {
status.textContent = 'Fel: ' + e.message;
btn.disabled = false;
}
}
async function wifiDisconnect() {
const status = document.getElementById('wifi-scan-status');
status.textContent = 'Kopplar från...';
try {
await apiFetch('/api/wifi/disconnect', { method: 'POST' });
status.textContent = '';
setTimeout(laddaWifiStatus, 1000);
} catch {}
}
async function wifiRemove(id, ssid) {
if (!confirm(`Ta bort sparat nätverk "${ssid}"?`)) return;
try {
await apiFetch('/api/wifi/saved/' + encodeURIComponent(id), { method: 'DELETE' });
laddaWifiSaved();
} catch {}
}
// --- Bluetooth ---
let _btPowered = true;
let _btScanning = false;
let _btScanTimer = null;
function _btDeviceIcon(type) {
if (type === 'audio') return '🔊';
if (type === 'phone') return '📱';
if (type === 'input') return '⌨';
return '📶';
}
function _btDeviceRow(dev, section) {
const icon = _btDeviceIcon(dev.type);
const label = escHtml(dev.name || dev.address);
const addrSpan = dev.name
? `<span style="font-size:.75rem;color:#555;margin-left:.4rem;font-family:monospace">${escHtml(dev.address)}</span>`
: '';
const nameHtml = `<span style="flex:1">${icon} ${label}${addrSpan}</span>`;
if (section === 'found') {
return `
<div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--surface);border-radius:6px">
${nameHtml}
<button class="secondary" style="padding:.2rem .6rem;font-size:.8rem" onclick="btPair('${escHtml(dev.address)}','${escHtml(dev.name)}')">Koppla</button>
</div>`;
}
const connBtn = dev.connected
? `<button class="secondary" style="padding:.2rem .6rem;font-size:.8rem" onclick="btDisconnect('${escHtml(dev.address)}')">Koppla från</button>`
: `<button class="secondary" style="padding:.2rem .6rem;font-size:.8rem" onclick="btConnect('${escHtml(dev.address)}')">Anslut</button>`;
const connDot = dev.connected
? `<span style="color:#22c55e;font-size:.75rem">● ansluten</span>`
: `<span style="color:#555;font-size:.75rem">○ ej ansluten</span>`;
return `
<div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--surface);border-radius:6px">
${nameHtml}
${connDot}
${connBtn}
<button class="danger" style="padding:.2rem .6rem;font-size:.8rem" onclick="btRemove('${escHtml(dev.address)}','${escHtml(dev.name || dev.address)}')">Ta bort</button>
</div>`;
}
async function laddaBluetooth() {
try {
const [stRes, devRes] = await Promise.all([
apiFetch('/api/bluetooth/status'),
apiFetch('/api/bluetooth/devices'),
]);
if (!stRes || !devRes) return;
const st = await stRes.json();
const dev = await devRes.json();
_btPowered = st.powered;
_btScanning = dev.scanning;
const dot = document.getElementById('bt-status-dot');
const nameEl = document.getElementById('bt-adapter-name');
const addrEl = document.getElementById('bt-adapter-addr');
const powerBtn = document.getElementById('bt-power-btn');
const scanBtn = document.getElementById('bt-scan-btn');
if (!st.available) {
dot.style.background = '#ef4444';
nameEl.textContent = 'Bluetooth ej tillgängligt';
return;
}
dot.style.background = st.powered ? '#22c55e' : '#555';
nameEl.textContent = st.name || 'Bluetooth';
addrEl.textContent = st.address || '';
powerBtn.textContent = st.powered ? 'Stäng av adapter' : 'Slå på adapter';
const known = dev.devices || [];
const paired = known.filter(d => d.paired);
const knownList = document.getElementById('bt-known-list');
if (paired.length) {
knownList.innerHTML = paired.map(d => _btDeviceRow(d, 'known')).join('');
} else {
knownList.innerHTML = '<span style="color:#555;font-style:italic">Inga kopplade enheter</span>';
}
if (_btScanning) {
scanBtn.disabled = true;
document.getElementById('bt-scan-status').textContent = 'Skannar... (15 s)';
_btPollScan();
}
} catch (e) {
document.getElementById('bt-adapter-name').textContent = 'Fel: ' + e.message;
}
}
async function btScan() {
if (_btScanning) return;
const btn = document.getElementById('bt-scan-btn');
const status = document.getElementById('bt-scan-status');
const wrap = document.getElementById('bt-found-wrap');
const list = document.getElementById('bt-found-list');
btn.disabled = true;
status.textContent = 'Skannar... (15 s)';
list.innerHTML = '<span style="color:#555;font-style:italic">Söker...</span>';
wrap.style.display = 'block';
try {
const res = await apiFetch('/api/bluetooth/scan', { method: 'POST' });
if (!res) { btn.disabled = false; return; }
_btScanning = true;
_btPollScan();
} catch (e) {
status.textContent = 'Fel: ' + e.message;
btn.disabled = false;
}
}
function _btPollScan() {
if (_btScanTimer) clearInterval(_btScanTimer);
const knownAddrs = new Set();
document.querySelectorAll('#bt-known-list [data-btaddr]').forEach(el => knownAddrs.add(el.dataset.btaddr));
_btScanTimer = setInterval(async () => {
try {
const res = await apiFetch('/api/bluetooth/devices');
if (!res) return;
const d = await res.json();
// Uppdatera kopplade enheter (parade)
const known = (d.devices || []);
const paired = known.filter(k => k.paired);
const knownList = document.getElementById('bt-known-list');
knownList.innerHTML = paired.length
? paired.map(dev => _btDeviceRow(dev, 'known')).join('')
: '<span style="color:#555;font-style:italic">Inga kopplade enheter</span>';
// Visa hittade men ej parade enheter under "Hittade enheter"
const foundList = document.getElementById('bt-found-list');
const found = known.filter(k => !k.paired);
if (found.length) {
foundList.innerHTML = found.map(dev => _btDeviceRow(dev, 'found')).join('');
}
if (!d.scanning) {
clearInterval(_btScanTimer);
_btScanTimer = null;
_btScanning = false;
document.getElementById('bt-scan-btn').disabled = false;
document.getElementById('bt-scan-status').textContent = found.length + ' enhet(er) hittades';
if (!found.length) {
document.getElementById('bt-found-list').innerHTML = '<span style="color:#555;font-style:italic">Inga nya enheter hittades</span>';
}
}
} catch {}
}, 2500);
}
async function btPair(addr, name) {
const status = document.getElementById('bt-scan-status');
status.textContent = `Kopplar ${name}...`;
try {
const res = await apiFetch('/api/bluetooth/pair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: addr }),
});
if (!res) return;
const d = await res.json();
if (d.ok) {
status.textContent = `✓ ${name} kopplad`;
await laddaBluetooth();
} else {
status.textContent = `✗ ${d.message || 'Misslyckades'}`;
}
} catch (e) {
status.textContent = 'Fel: ' + e.message;
}
}
async function btConnect(addr) {
const status = document.getElementById('bt-scan-status');
status.textContent = 'Ansluter...';
try {
const res = await apiFetch('/api/bluetooth/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: addr }),
});
if (!res) return;
const d = await res.json();
status.textContent = d.ok ? '✓ Ansluten' : '✗ ' + (d.message || 'Misslyckades');
await laddaBluetooth();
} catch (e) {
status.textContent = 'Fel: ' + e.message;
}
}
async function btDisconnect(addr) {
await apiFetch('/api/bluetooth/disconnect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: addr }),
});
await laddaBluetooth();
}
async function btRemove(addr, name) {
if (!confirm(`Ta bort "${name}" från kopplade enheter?`)) return;
await apiFetch('/api/bluetooth/device/' + encodeURIComponent(addr), { method: 'DELETE' });
await laddaBluetooth();
}
async function btTogglePower() {
const btn = document.getElementById('bt-power-btn');
btn.disabled = true;
try {
const res = await apiFetch('/api/bluetooth/power', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: !_btPowered }),
});
if (!res) { btn.disabled = false; return; }
const st = await res.json();
_btPowered = st.powered;
document.getElementById('bt-status-dot').style.background = st.powered ? '#22c55e' : '#555';
btn.textContent = st.powered ? 'Stäng av adapter' : 'Slå på adapter';
} catch {}
btn.disabled = false;
}
// --- Klocka ---
let _clockPos = 'top-right';
function setClockPos(pos) {
_clockPos = pos;
document.querySelectorAll('.clock-pos-btn').forEach(b => b.classList.remove('active'));
const btn = document.getElementById('clkpos-' + pos);
if (btn) btn.classList.add('active');
}
async function laddaClock() {
const res = await apiFetch('/api/clock');
if (!res) return;
const d = await res.json();
document.getElementById('clock-style').value = d.style || 'digital';
document.getElementById('clock-show-date').checked = !!d.show_date;
document.getElementById('clock-size').value = d.size != null ? d.size : 2;
document.getElementById('clock-size-val').textContent = d.size != null ? d.size : 2;
document.getElementById('clock-color').value = d.color || '#ffffff';
document.getElementById('clock-opacity').value = d.opacity != null ? d.opacity : 90;
document.getElementById('clock-opacity-val').textContent = (d.opacity != null ? d.opacity : 90) + '%';
document.getElementById('clock-schedule-mode').value = d.schedule_mode || 'always';
setClockPos(d.position || 'top-right');
document.getElementById('clock-status').textContent = d.enabled ? '● Aktiv' : '';
}
async function _sendClock(enabled) {
const res = await apiFetch('/api/clock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled,
style: document.getElementById('clock-style').value,
show_date: document.getElementById('clock-show-date').checked,
position: _clockPos,
size: parseFloat(document.getElementById('clock-size').value),
color: document.getElementById('clock-color').value,
opacity: parseInt(document.getElementById('clock-opacity').value, 10),
schedule_mode: document.getElementById('clock-schedule-mode').value,
}),
});
if (!res) return;
document.getElementById('clock-status').textContent = enabled ? '● Aktiv' : '';
}
function aktiveraClock() { _sendClock(true); }
function avaktiveraClock() { _sendClock(false); }
// --- Musik ---
function musicSourceTypeChanged() {
const isYt = document.getElementById('music-source-type').value === 'youtube';
document.getElementById('music-local-label').style.display = isYt ? 'none' : '';
document.getElementById('music-local-wrap').style.display = isYt ? 'none' : '';
document.getElementById('music-yt-label').style.display = isYt ? '' : 'none';
document.getElementById('music-yt-wrap').style.display = isYt ? '' : 'none';
document.getElementById('music-track-list-wrap').style.display = 'none';
}
function openMusicFolderBrowser() {
// Öppna filbläddraren men tillåt val av mapp
_browserCallback = (path) => {
// Användaren valde en fil — ta mappens sökväg
const folder = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : path;
document.getElementById('music-source-local').value = folder;
laddaMusicTracks(folder);
};
document.getElementById('browser-modal').style.display = 'flex';
browseTo('/home/mrfox');
}
async function laddaMusicTracks(folder) {
if (!folder) return;
const wrap = document.getElementById('music-track-list-wrap');
const list = document.getElementById('music-track-list');
list.textContent = 'Laddar...';
wrap.style.display = 'block';
try {
const res = await apiFetch('/api/music/tracks?path=' + encodeURIComponent(folder));
if (!res) return;
const tracks = await res.json();
if (!tracks.length) {
list.textContent = 'Inga musikfiler hittades i mappen.';
} else {
list.innerHTML = tracks.map((t, i) =>
`<div>${i + 1}. ${escHtml(t.name)}</div>`
).join('');
}
} catch {
list.textContent = 'Kunde inte lista spår.';
}
}
async function laddaMusik() {
const res = await apiFetch('/api/music');
if (!res) return;
const d = await res.json();
document.getElementById('music-source-type').value = d.source_type || 'local';
document.getElementById('music-source-local').value = d.source_type === 'local' ? d.source : '';
document.getElementById('music-source-youtube').value = d.source_type === 'youtube' ? d.source : '';
document.getElementById('music-volume').value = d.volume != null ? d.volume : 50;
document.getElementById('music-volume-val').textContent = (d.volume != null ? d.volume : 50) + '%';
document.getElementById('music-shuffle').checked = !!d.shuffle;
document.getElementById('music-show-overlay').checked = d.show_overlay !== false;
document.getElementById('music-overlay-position').value = d.overlay_position || 'bottom-left';
document.getElementById('music-status').textContent = d.enabled ? '● Aktiv' : '';
musicSourceTypeChanged();
// Visa spårlista om lokal mapp är satt
if (d.source_type === 'local' && d.source) {
await laddaMusicTracks(d.source);
}
// Visa "Spelar nu"-banner om musik spelas
if (d.now_playing && (d.now_playing.title || d.now_playing.artist)) {
_updateNowPlayingBanner(d.now_playing.title, d.now_playing.artist);
}
}
async function extractMusicPlaylist() {
const url = document.getElementById('music-source-youtube').value.trim();
const status = document.getElementById('music-extract-status');
const btn = document.getElementById('music-extract-btn');
if (!url) { status.textContent = 'Ange en URL först.'; return; }
btn.disabled = true;
status.textContent = 'Hämtar spellista…';
try {
const res = await apiFetch('/api/music/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const d = await res.json();
if (!res.ok) {
status.textContent = '⚠ ' + (d.error || 'Okänt fel');
} else {
status.style.color = '#166534';
status.textContent = `✓ ${d.count} låtar hämtade och redo att spelas.`;
}
} catch (e) {
status.textContent = '⚠ Nätverksfel: ' + e.message;
}
btn.disabled = false;
}
async function setMusik(enabled) {
const sourceType = document.getElementById('music-source-type').value;
const rawSource = sourceType === 'youtube'
? document.getElementById('music-source-youtube').value.trim()
: document.getElementById('music-source-local').value.trim();
const source = sourceType === 'youtube' ? normalizeMusicYouTubeUrl(rawSource) : rawSource;
const res = await apiFetch('/api/music', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled,
source_type: sourceType,
source,
volume: parseInt(document.getElementById('music-volume').value, 10),
shuffle: document.getElementById('music-shuffle').checked,
show_overlay: document.getElementById('music-show-overlay').checked,
overlay_position: document.getElementById('music-overlay-position').value,
}),
});
if (!res) return;
document.getElementById('music-status').textContent = enabled ? '● Aktiv' : '';
}
function _updateNowPlayingBanner(title, artist) {
const banner = document.getElementById('now-playing-banner');
const text = document.getElementById('np-banner-text');
if (!banner) return;
if (title || artist) {
const label = artist ? `${artist} — ${title}` : title;
text.textContent = 'Spelar nu: ' + label;
banner.style.display = 'flex';
} else {
banner.style.display = 'none';
}
}
// Lyssna på now_playing-händelser från servern
socket.on('now_playing', (data) => {
_updateNowPlayingBanner(data.title || '', data.artist || '');
});
// --- Föreslagna uppspelningar från chatten ---
socket.on('play_suggestion', (data) => {
const area = document.getElementById('play-suggestions');
if (!area) return;
const url = data.url || '';
const short = url.length > 55 ? url.slice(0, 52) + '…' : url;
const item = document.createElement('div');
item.style.cssText = 'display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;background:rgba(129,140,248,.1);border:1px solid rgba(129,140,248,.25);border-radius:8px;font-size:.88rem;';
item.innerHTML = `
<span style="font-size:1rem;flex-shrink:0">🎬</span>
<span style="flex:1;min-width:0"><b style="color:#c4b5fd">${escHtml(data.name)}</b> vill visa: <span style="color:#818cf8;word-break:break-all">${escHtml(short)}</span></span>
<button class="secondary" style="padding:.25rem .65rem;font-size:.82rem;flex-shrink:0" onclick="visaSuggestion('${escHtml(url)}',this.closest('div'))">Spela nu</button>
<button style="background:none;border:none;color:#555;cursor:pointer;font-size:1.1rem;line-height:1;padding:.1rem .3rem;flex-shrink:0" onclick="this.closest('div').remove();_updateSuggestionsVisibility()">✕</button>`;
area.prepend(item);
area.style.display = 'flex';
});
function _updateSuggestionsVisibility() {
const area = document.getElementById('play-suggestions');
if (area && !area.children.length) area.style.display = 'none';
}
function visaSuggestion(url, itemEl) {
const embed = youtubeEmbedUrl(url) || youtubePlaylistEmbedUrl(url);
socket.emit('admin_switch', { type: 'url', source: embed || url });
if (itemEl) { itemEl.remove(); _updateSuggestionsVisibility(); }
}
// --- Sovläge ---
async function laddaSovlage() {
const res = await apiFetch('/api/sleep');
if (!res) return;
const d = await res.json();
document.getElementById('sleep-enabled').checked = !!d.enabled;
document.getElementById('sleep-start').value = d.start || '22:00';
document.getElementById('sleep-end').value = d.end || '07:00';
}
async function sparaSovlage() {
const status = document.getElementById('sleep-status');
status.textContent = 'Sparar...';
const res = await apiFetch('/api/sleep', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: document.getElementById('sleep-enabled').checked,
start: document.getElementById('sleep-start').value,
end: document.getElementById('sleep-end').value,
}),
});
if (!res) return;
status.textContent = '✓ Sparat';
setTimeout(() => { status.textContent = ''; }, 3000);
}
// --- Systeminfo ---
async function laddaSysinfo() {
const res = await apiFetch('/api/sysinfo');
if (!res) return;
const d = await res.json();
document.getElementById('cpu').textContent = d.cpu + '%';
document.getElementById('ram').textContent = d.ram + '%';
document.getElementById('disk').textContent = d.disk + '%';
document.getElementById('uptime').textContent = d.uptime;
document.getElementById('active-source').textContent =
d.active_type !== '—' ? `[${d.active_type}] ${d.active_source}` : '—';
}
// --- Filbläddrare ---
let _browserCallback = null;
const FILE_TYPES = {
video: ['mp4','webm','avi','mkv','mov'],
image: ['jpg','jpeg','png','gif','webp','bmp'],
pdf: ['pdf','ppt','pptx','odp'],
url: ['html','htm'],
};
function extType(filename) {
const ext = filename.split('.').pop().toLowerCase();
for (const [type, exts] of Object.entries(FILE_TYPES)) {
if (exts.includes(ext)) return type;
}
return 'url';
}
function extClass(filename) {
const t = extType(filename);
return t === 'url' ? 'file-html' : 'file-' + t;
}
function openBrowser(callback) {
_browserCallback = callback;
document.getElementById('browser-modal').style.display = 'flex';
browseTo('/home/mrfox');
}
function closeBrowser() {
document.getElementById('browser-modal').style.display = 'none';
_browserCallback = null;
}
function browserOverlayClick(e) {
if (e.target === document.getElementById('browser-modal')) closeBrowser();
}
async function browseTo(path) {
const res = await apiFetch('/api/browse?path=' + encodeURIComponent(path));
if (!res) return;
const data = await res.json();
document.getElementById('browser-path').textContent = data.path;
let html = '';
if (data.parent) {
html += `<div class="browser-entry dir" data-path="${escHtml(data.parent)}">.. (upp)</div>`;
}
for (const e of data.entries) {
if (e.type === 'dir') {
html += `<div class="browser-entry dir" data-path="${escHtml(e.path)}">${escHtml(e.name)}</div>`;
} else {
html += `<div class="browser-entry ${escHtml(extClass(e.name))}" data-path="${escHtml(e.path)}">${escHtml(e.name)}</div>`;
}
}
if (!html) html = '<div style="padding:.75rem 1rem;color:#555;font-style:italic">Inga filer hittades</div>';
const list = document.getElementById('browser-list');
list.innerHTML = html;
list.querySelectorAll('.browser-entry.dir').forEach(el =>
el.addEventListener('click', () => browseTo(el.dataset.path))
);
list.querySelectorAll('.browser-entry:not(.dir)').forEach(el =>
el.addEventListener('click', () => {
if (_browserCallback) _browserCallback(el.dataset.path, extType(el.dataset.path));
closeBrowser();
})
);
}
// --- Helpers ---
function fmtDuration(mins) {
const totalSec = Math.round(mins * 60);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return m > 0 ? `${m} min ${s > 0 ? s + ' sek' : ''}`.trim() : `${s} sek`;
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// --- Övergångar ---
async function laddaTransition() {
try {
const res = await apiFetch('/api/transition');
if (!res) return;
const d = await res.json();
const sel = document.getElementById('tx-type');
const dur = document.getElementById('tx-duration');
if (sel) sel.value = d.type || 'fade';
if (dur) dur.value = d.duration || 600;
} catch {}
}
async function saveTransition() {
const type = document.getElementById('tx-type').value;
const duration = parseInt(document.getElementById('tx-duration').value, 10) || 600;
const status = document.getElementById('tx-status');
try {
const res = await apiFetch('/api/transition', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, duration }),
});
if (!res) return;
status.textContent = 'Sparat!';
setTimeout(() => { status.textContent = ''; }, 2000);
} catch (e) {
status.textContent = 'Fel: ' + e.message;
}
}
// --- Init ---
async function init() {
await laddaSysinfo();
await laddaJobb();
await laddaInstallningar();
await laddaVolym();
await laddaTicker();
await laddaChat();
await laddaYtChat();
await laddaClock();
await laddaMusik();
await laddaWifiStatus();
await laddaBluetooth();
await laddaWifiSaved();
await laddaSovlage();
await laddaTransition();
setInterval(laddaSysinfo, 10000);
const res = await apiFetch('/api/sysinfo');
if (!res) return;
const d = await res.json();
if (d.active_type === 'url') {
document.getElementById('url-input').value = d.active_source;
}
}
init();
// Theme toggle control for admin UI
(function(){
const KEY = 'kiosk_theme';
function getTheme(){ return localStorage.getItem(KEY) || 'auto'; }
function applyTheme(pref){
if (pref === 'auto') {
delete document.documentElement.dataset.theme;
} else {
document.documentElement.dataset.theme = pref;
}
}
function cycleTheme(){
const order = ['auto','dark','light'];
const cur = getTheme();
const next = order[(order.indexOf(cur)+1) % order.length];
localStorage.setItem(KEY, next);
applyTheme(next);
updateBtn();
// notify other windows/tabs
window.dispatchEvent(new Event('kiosk-theme-changed'));
}
function updateBtn(){
const t = getTheme();
btn.textContent = t === 'auto' ? 'Tema: Auto' : (t === 'dark' ? 'Tema: Mörkt' : 'Tema: Ljust');
}
const container = document.querySelector('.container');
if (!container) return;
const btn = document.createElement('button');
btn.className = 'secondary';
btn.style.marginLeft = 'auto';
btn.style.marginBottom = '1rem';
btn.addEventListener('click', cycleTheme);
// Insert button under the H1
const h1 = container.querySelector('h1');
if (h1) h1.insertAdjacentElement('afterend', btn);
applyTheme(getTheme());
updateBtn();
// React to storage changes from other tabs
window.addEventListener('storage', (e) => {
if (e.key === KEY) { applyTheme(e.newValue || 'auto'); updateBtn(); }
});
// Also react to programmatic events
window.addEventListener('kiosk-theme-changed', () => { applyTheme(getTheme()); updateBtn(); });
})();