Files
jukebox_maker/web/templates/dashboard.html
Michael Chus e885e49647 Show disk profile panel via Settings button
Profile panel is now hidden by default; a gear Settings button in
the disk card toggles it open/closed. Reduces visual clutter for
the common case when no profile changes are needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 21:09:52 +03:00

428 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "content"}}
<section class="panel">
<h2>Mounted Disk</h2>
<div class="panel-body">
<div class="path-input-row">
<input class="form-input" type="text" id="mountPath" placeholder="/Volumes/JUKEBOX or E:\\">
<button type="button" class="button-secondary" onclick="refreshSelectedDisk()">Refresh</button>
</div>
<div class="form-hint">Choose the directory where the removable disk is mounted. The app works with one selected disk at a time in standalone mode.</div>
</div>
</section>
<div id="diskState"></div>
<script>
const selectedDisk = { info: null };
const taskState = new Map();
const taskPollers = new Map();
function escapeHTML(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function badgeClass(state) {
return ({ absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' })[state] || 'badge-unknown';
}
function badgeLabel(state) {
return ({ absent: 'Directory unavailable', foreign: 'Uninitialized disk', known: 'Ready' })[state] || '—';
}
function fmtSpeed(bps) {
if (!bps) return '';
if (bps >= 1e9) return (bps / 1e9).toFixed(1) + ' GB/s';
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' MB/s';
if (bps >= 1e3) return (bps / 1e3).toFixed(0) + ' KB/s';
return bps + ' B/s';
}
function fmtETA(sec) {
if (!sec || sec <= 0) return '';
if (sec >= 3600) return Math.floor(sec / 3600) + ' h ' + Math.floor((sec % 3600) / 60) + ' min';
if (sec >= 60) return Math.floor(sec / 60) + ' min';
return sec + ' s';
}
function fmtDateTime(value) {
if (!value) return 'Never';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function taskMeta(task) {
if (!task) return '';
return [fmtSpeed(task.speed_bps), task.eta_sec ? 'ETA: ' + fmtETA(task.eta_sec) : ''].filter(Boolean).join(' · ');
}
function renderDisk() {
const root = document.getElementById('diskState');
const disk = selectedDisk.info;
if (!disk) {
root.innerHTML = `
<section class="panel">
<div class="panel-body text-muted">Choose a mounted disk directory to inspect it.</div>
</section>
`;
return;
}
const activeTask = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
const progress = activeTask ? activeTask.progress : 0;
const message = activeTask ? (activeTask.message || 'Preparing...') : '';
const meta = activeTask ? taskMeta(activeTask) : '';
const isKnown = disk.state === 'known';
const isForeign = disk.state === 'foreign';
const hasCapacity = disk.state !== 'absent';
root.innerHTML = `
<section class="panel disk-card">
<h2>${escapeHTML(disk.mount_path)}</h2>
<table class="kv-table">
<tbody>
<tr>
<th>Status</th>
<td><span class="badge ${badgeClass(disk.state)}">${badgeLabel(disk.state)}</span></td>
</tr>
<tr>
<th>Disk ID</th>
<td>${disk.disk_id ? `<span class="mono">${escapeHTML(disk.disk_id)}</span>` : '<span class="text-muted">not initialized yet</span>'}</td>
</tr>
<tr>
<th>Total capacity</th>
<td>${hasCapacity ? fmtBytes(disk.total_bytes) : '—'}</td>
</tr>
<tr>
<th>Free space</th>
<td>${hasCapacity ? fmtBytes(disk.free_bytes) : '—'}</td>
</tr>
<tr>
<th>Last copied</th>
<td>${fmtDateTime(disk.last_copied_at)}</td>
</tr>
</tbody>
</table>
${activeTask ? `
<div class="panel-body progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" style="width:${progress}%"></div>
</div>
<div class="progress-label">${escapeHTML(message)}</div>
<div class="progress-label">${escapeHTML(meta)}</div>
</div>
` : ''}
<div class="btn-row">
${isKnown ? `
<button class="button-danger" data-action="start-copy" data-mode="replace" ${activeTask ? 'disabled' : ''}>Replace media</button>
<button class="button-primary" data-action="start-copy" data-mode="add" ${activeTask ? 'disabled' : ''}>Add media</button>
<button class="button-danger ${activeTask ? '' : 'hidden'}" data-action="cancel-copy">Cancel</button>
<button class="button-secondary" data-action="disk-settings" style="margin-left:auto">⚙ Settings</button>
` : ''}
${isForeign ? `
<button class="button-secondary" data-action="init-disk">Initialize disk</button>
` : ''}
</div>
</section>
${isKnown ? renderProfile(disk) : ''}
`;
}
function renderProfile(disk) {
const p = disk.profile || {};
const t = p.transcode || null;
const transcodeEnabled = !!t;
const sel = (name, value, options) => {
const opts = options.map(([v, label]) =>
`<option value="${v}" ${v === value ? 'selected' : ''}>${escapeHTML(label)}</option>`
).join('');
return `<select class="form-input" id="prof_${name}">${opts}</select>`;
};
const transcodeSection = `
<div id="transcodeFields" style="${transcodeEnabled ? '' : 'display:none'}">
<div class="form-group">
<label>Видеокодек</label>
${sel('video_codec', t?.video_codec || 'h264', [['h264','H.264 (AVC)'],['h265','H.265 (HEVC)'],['mpeg4','MPEG-4']])}
</div>
<div class="form-group">
<label>Макс. разрешение</label>
${sel('max_resolution', t?.max_resolution || '720p', [['480p','480p'],['720p','720p (HD)'],['1080p','1080p (Full HD)']])}
</div>
<div class="form-group">
<label>Макс. битрейт видео</label>
${sel('max_video_bitrate', t?.max_video_bitrate || '2000k', [['','Без лимита'],['1000k','1000 кбит/с'],['2000k','2000 кбит/с'],['4000k','4000 кбит/с'],['8000k','8000 кбит/с']])}
</div>
<div class="form-group">
<label>Макс. FPS</label>
${sel('max_fps', String(t?.max_fps ?? 0), [['0','Без лимита'],['24','24'],['25','25'],['30','30']])}
</div>
<hr>
<div class="form-group">
<label>Аудиокодек</label>
${sel('audio_codec', t?.audio_codec || 'aac', [['aac','AAC'],['mp3','MP3']])}
</div>
<div class="form-group">
<label>Макс. битрейт аудио</label>
${sel('max_audio_bitrate', t?.max_audio_bitrate || '192k', [['','Без лимита'],['128k','128 кбит/с'],['192k','192 кбит/с'],['320k','320 кбит/с']])}
</div>
<div class="form-group">
<label>Каналы</label>
${sel('max_audio_channels', String(t?.max_audio_channels ?? 0), [['0','Копировать'],['2','Стерео (2.0)'],['6','5.1']])}
</div>
<hr>
<div class="form-group">
<label>Формат контейнера</label>
${sel('output_format', t?.output_format || 'mp4', [['mp4','MP4'],['mkv','MKV'],['avi','AVI']])}
</div>
</div>
`;
return `
<section class="panel" id="profilePanel" style="display:none">
<h2>Профиль диска</h2>
<div class="panel-body">
<h3>Параметры копирования</h3>
<div class="form-group">
<label>Папка назначения</label>
<input class="form-input" type="text" id="prof_dest_folder" value="${escapeHTML(p.dest_folder || 'media')}">
</div>
<div class="form-group">
<label>Режим перезаписи</label>
${sel('overwrite_mode', p.overwrite_mode || 'skip', [['skip','Пропускать существующие'],['delete','Заменять всё']])}
</div>
<div class="form-group">
<label>Выбор файлов</label>
${sel('file_select_mode', p.file_select_mode || 'new', [['new','Только новые'],['all','Все подходящие']])}
</div>
<div class="form-group">
<label>Резерв свободного места (ГБ)</label>
<input class="form-input" type="number" id="prof_reserve_free_gb" value="${p.reserve_free_gb ?? 2}" min="0" step="0.5">
</div>
<div class="form-group">
<label><input type="checkbox" id="prof_auto_copy" ${p.auto_copy ? 'checked' : ''}> Автокопирование при подключении</label>
</div>
<h3 style="margin-top:1.5em">Транскодирование видео</h3>
<div class="form-group">
<label>
<input type="checkbox" id="prof_transcode_enabled" ${transcodeEnabled ? 'checked' : ''}
onchange="document.getElementById('transcodeFields').style.display=this.checked?'':'none'">
Ограничить видео под устройство
</label>
</div>
${transcodeSection}
<div class="btn-row" style="margin-top:1em">
<button class="button-primary" onclick="saveProfile('${escapeHTML(disk.mount_path)}')">Сохранить профиль</button>
</div>
</div>
</section>
`;
}
async function saveProfile(mountPath) {
const g = id => document.getElementById(id);
const transcodeEnabled = g('prof_transcode_enabled')?.checked;
const profile = {
dest_folder: g('prof_dest_folder')?.value.trim() || 'media',
overwrite_mode: g('prof_overwrite_mode')?.value || 'skip',
file_select_mode: g('prof_file_select_mode')?.value || 'new',
reserve_free_gb: parseFloat(g('prof_reserve_free_gb')?.value || '2') || 0,
auto_copy: g('prof_auto_copy')?.checked || false,
};
if (transcodeEnabled) {
profile.transcode = {
video_codec: g('prof_video_codec')?.value || 'h264',
max_resolution: g('prof_max_resolution')?.value || '720p',
max_video_bitrate: g('prof_max_video_bitrate')?.value || '',
max_fps: parseInt(g('prof_max_fps')?.value || '0', 10),
audio_codec: g('prof_audio_codec')?.value || 'aac',
max_audio_bitrate: g('prof_max_audio_bitrate')?.value || '',
max_audio_channels: parseInt(g('prof_max_audio_channels')?.value || '0', 10),
output_format: g('prof_output_format')?.value || 'mp4',
};
}
try {
const response = await fetch('/api/disks/profile?mount_path=' + encodeURIComponent(mountPath), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile)
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Ошибка сохранения профиля', 'error');
return;
}
toast('Профиль сохранён', 'ok');
refreshSelectedDisk();
} catch (error) {
toast('Ошибка сети', 'error');
}
}
function stopTaskPoll(taskID) {
if (!taskPollers.has(taskID)) return;
clearInterval(taskPollers.get(taskID));
taskPollers.delete(taskID);
}
function startTaskPoll(taskID) {
if (!taskID || taskPollers.has(taskID)) return;
taskPollers.set(taskID, setInterval(() => pollTask(taskID), 1500));
pollTask(taskID);
}
async function refreshSelectedDisk() {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) {
selectedDisk.info = null;
renderDisk();
return;
}
localStorage.setItem('jukebox.selectedMountPath', mountPath);
try {
const response = await fetch('/api/disks/probe?mount_path=' + encodeURIComponent(mountPath));
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to inspect directory', 'error');
return;
}
selectedDisk.info = payload;
renderDisk();
if (payload.active_task_id) {
for (const taskID of Array.from(taskPollers.keys())) {
if (taskID !== payload.active_task_id) {
stopTaskPoll(taskID);
taskState.delete(taskID);
}
}
startTaskPoll(payload.active_task_id);
} else {
for (const taskID of Array.from(taskPollers.keys())) {
stopTaskPoll(taskID);
taskState.delete(taskID);
}
}
} catch (error) {
toast('Network error', 'error');
}
}
async function pollTask(taskID) {
try {
const response = await fetch('/api/tasks/' + taskID);
if (!response.ok) return;
const task = await response.json();
taskState.set(taskID, task);
renderDisk();
if (['success', 'failed', 'canceled'].includes(task.status)) {
stopTaskPoll(taskID);
taskState.delete(taskID);
if (task.status === 'success') toast(task.message || 'Done', 'ok');
if (task.status === 'failed') toast('Error: ' + task.error, 'error');
if (task.status === 'canceled') toast('Copy canceled', 'error');
refreshSelectedDisk();
}
} catch (error) {}
}
async function startCopy(mode) {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) return;
try {
const response = await fetch('/api/disks/copy/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mount_path: mountPath, mode })
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to start copy', 'error');
return;
}
startTaskPoll(payload.task_id);
refreshSelectedDisk();
} catch (error) {
toast('Network error', 'error');
}
}
async function cancelCopy() {
if (!selectedDisk.info || !selectedDisk.info.disk_id) return;
try {
await fetch('/api/disks/' + encodeURIComponent(selectedDisk.info.disk_id) + '/copy/cancel', { method: 'POST' });
toast('Canceling...', 'ok');
} catch (error) {
toast('Network error', 'error');
}
}
async function initDisk() {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) return;
try {
const response = await fetch('/api/disks/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mount_path: mountPath })
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to initialize disk', 'error');
return;
}
toast('Disk initialized', 'ok');
refreshSelectedDisk();
} catch (error) {
toast('Network error', 'error');
}
}
document.getElementById('diskState').addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
if (action === 'start-copy') startCopy(button.dataset.mode || 'add');
if (action === 'cancel-copy') cancelCopy();
if (action === 'init-disk') initDisk();
if (action === 'disk-settings') {
const panel = document.getElementById('profilePanel');
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : '';
button.textContent = open ? '⚙ Settings' : '⚙ Settings ✕';
}
});
const savedMountPath = localStorage.getItem('jukebox.selectedMountPath');
if (savedMountPath) {
document.getElementById('mountPath').value = savedMountPath;
refreshSelectedDisk();
} else {
renderDisk();
}
</script>
{{end}}