Add duration prewarm button; show video lengths in the loaded-course view
Settings gets a "Precompute Video Lengths" button that walks the whole library in a background thread, populating every course's persistent ffprobe duration cache up front instead of paying that cost the first time each course card is viewed. Progress polls live while it runs and picks back up correctly if you navigate away mid-scan. Separately, the loaded-course lesson tree only ever showed a lesson's duration once you'd actually played it (from progress.json) - a freshly opened course had no time info anywhere, including the course header's "~X remaining" line, which existed but was always empty as a result. apply_progress_to_tree now falls back to the same ffprobe cache for any lesson without a known duration yet, so per-lesson lengths and the remaining-time estimate work from the very first visit. Refactored the duration-cache read/write into a shared helper so the library browser, the prewarm button, and this tree view all go through one path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -896,6 +896,12 @@
|
||||
.lesson-type.quiz { background: #f39c12; color: white; }
|
||||
.lesson-type.mixed { background: #16a085; color: white; }
|
||||
|
||||
.lesson-duration {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
@@ -1081,6 +1087,9 @@
|
||||
</div>
|
||||
<div class="lesson-meta">
|
||||
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
|
||||
{% if lesson.duration_seconds and lesson.duration_seconds >= 60 %}
|
||||
<span class="lesson-duration">{{ lesson.duration_seconds|format_duration }}</span>
|
||||
{% endif %}
|
||||
{% if lesson.completed %}
|
||||
<span class="status-icon completed">{{ icons.icon('check', 14) }}</span>
|
||||
{% elif percent_watched %}
|
||||
|
||||
@@ -449,6 +449,12 @@
|
||||
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
||||
</div>
|
||||
<span class="setting-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</span>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
|
||||
<button class="btn btn-secondary btn-sm" id="prewarm-durations-btn" onclick="startDurationPrewarm()">{{ icons.icon('refresh', 14) }} Precompute Video Lengths</button>
|
||||
<span id="prewarm-durations-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
||||
</div>
|
||||
<span class="setting-desc">Reads every video/audio file's length up front and caches it, so course cards in the Library show their total runtime immediately instead of computing it the first time each course is viewed.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -999,6 +1005,69 @@
|
||||
});
|
||||
}
|
||||
|
||||
function updatePrewarmStatus(data) {
|
||||
const btn = document.getElementById('prewarm-durations-btn');
|
||||
const status = document.getElementById('prewarm-durations-status');
|
||||
if (data.running) {
|
||||
btn.disabled = true;
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = `Scanning… ${data.done}/${data.total} courses`;
|
||||
return;
|
||||
}
|
||||
btn.disabled = false;
|
||||
if (data.error) {
|
||||
status.style.color = 'var(--error)';
|
||||
status.textContent = `Error: ${data.error}`;
|
||||
} else if (data.total) {
|
||||
status.style.color = 'var(--success)';
|
||||
status.innerHTML = `${ICON_SVGS.check} Scanned ${data.total} course${data.total === 1 ? '' : 's'}`;
|
||||
setTimeout(() => { status.textContent = ''; }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function pollPrewarmStatus() {
|
||||
fetch('/api/library/prewarm-durations/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
updatePrewarmStatus(data);
|
||||
if (data.running) setTimeout(pollPrewarmStatus, 1500);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function startDurationPrewarm() {
|
||||
const status = document.getElementById('prewarm-durations-status');
|
||||
document.getElementById('prewarm-durations-btn').disabled = true;
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = 'Starting…';
|
||||
fetch('/api/library/prewarm-durations', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
updatePrewarmStatus(data);
|
||||
if (data.running) setTimeout(pollPrewarmStatus, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
status.style.color = 'var(--error)';
|
||||
status.textContent = 'Could not reach the server';
|
||||
document.getElementById('prewarm-durations-btn').disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Pick back up a prewarm that's already running from a previous
|
||||
// page load, rather than leaving the button looking idle while
|
||||
// work is actually happening in the background.
|
||||
(function resumePrewarmStatusIfRunning() {
|
||||
fetch('/api/library/prewarm-durations/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.running) {
|
||||
updatePrewarmStatus(data);
|
||||
setTimeout(pollPrewarmStatus, 1500);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
})();
|
||||
|
||||
function loadCourseFromPath() {
|
||||
const input = document.getElementById('manual-course-path');
|
||||
const status = document.getElementById('manual-course-status');
|
||||
|
||||
Reference in New Issue
Block a user