Add autoplay, progress rings, duplicate-lesson detection, title-card thumbnails

- Auto-play next lesson on end, with a cancelable countdown and a
  Settings toggle (default on).
- Grid-view course cards show a small progress ring (checkmark at
  100%) instead of a separate bar.
- Duplicate Lesson Files: scans within each course/folder for media
  files that look like the same lesson downloaded twice, with per-file
  delete and a shared ignore list with Duplicate Courses.
- Auto-generated cover art now samples a few early candidate frames
  and keeps the largest JPEG, favoring an intro title card over a
  blank fade-in or a plain presenter frame.
- Settings -> "Regenerate Thumbnails" re-runs that logic for every
  course with an auto-generated thumbnail (never touches manual
  covers), so already-cached thumbnails can pick up the improvement.
- Add .gitignore for __pycache__/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 20:20:36 -04:00
co-authored by Claude Sonnet 5
parent fc83876abe
commit 36d9e7f89b
9 changed files with 531 additions and 40 deletions
+80
View File
@@ -359,6 +359,12 @@
<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 and generates cover art for any course without one, up front - so course cards in the Library show their runtime and artwork immediately instead of computing it the first time each course is viewed.</span>
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
<button class="btn btn-secondary btn-sm" id="regen-thumbnails-btn" onclick="startThumbnailRegen()">{{ icons.icon('refresh', 14) }} Regenerate Thumbnails</button>
<span id="regen-thumbnails-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div>
<span class="setting-desc">Re-runs auto-generated cover art from scratch for every course that has one (not manually-placed covers) - useful after an improvement to how thumbnails are picked, since existing ones are otherwise cached forever.</span>
</div>
</div>
@@ -412,6 +418,12 @@
{% endfor %}
</select>
</div>
<div class="setting-row">
<label for="autoplay_next">Auto-play next lesson
<span class="setting-desc">When a lesson finishes, automatically move on to the next one after a few seconds (cancelable)</span>
</label>
<input type="checkbox" id="autoplay_next" data-setting="autoplay_next" {% if settings.autoplay_next %}checked{% endif %}>
</div>
</div>
<div class="card">
@@ -541,6 +553,10 @@
el.addEventListener('change', () => saveSetting(el.dataset.setting, el.value));
});
document.querySelectorAll('input[type="checkbox"][data-setting]').forEach(el => {
el.addEventListener('change', () => saveSetting(el.dataset.setting, el.checked));
});
const accentPicker = document.getElementById('accent-picker');
const accentHex = document.getElementById('accent-hex');
@@ -648,6 +664,70 @@
.catch(() => {});
})();
function updateThumbnailRegenStatus(data) {
const btn = document.getElementById('regen-thumbnails-btn');
const status = document.getElementById('regen-thumbnails-status');
if (data.running) {
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = `Regenerating… ${data.done}/${data.total}`;
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} Regenerated ${data.total} thumbnail${data.total === 1 ? '' : 's'}`;
setTimeout(() => { status.textContent = ''; }, 5000);
} else {
status.style.color = 'var(--text-muted)';
status.textContent = 'No auto-generated thumbnails to regenerate';
setTimeout(() => { status.textContent = ''; }, 5000);
}
}
function pollThumbnailRegenStatus() {
fetch('/api/library/regenerate-thumbnails/status')
.then(r => r.json())
.then(data => {
updateThumbnailRegenStatus(data);
if (data.running) setTimeout(pollThumbnailRegenStatus, 1500);
})
.catch(() => {});
}
function startThumbnailRegen() {
const status = document.getElementById('regen-thumbnails-status');
document.getElementById('regen-thumbnails-btn').disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Starting…';
fetch('/api/library/regenerate-thumbnails', { method: 'POST' })
.then(r => r.json())
.then(data => {
updateThumbnailRegenStatus(data);
if (data.running) setTimeout(pollThumbnailRegenStatus, 1500);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
document.getElementById('regen-thumbnails-btn').disabled = false;
});
}
(function resumeThumbnailRegenStatusIfRunning() {
fetch('/api/library/regenerate-thumbnails/status')
.then(r => r.json())
.then(data => {
if (data.running) {
updateThumbnailRegenStatus(data);
setTimeout(pollThumbnailRegenStatus, 1500);
}
})
.catch(() => {});
})();
function handleRestoreFileChosen(input) {
const file = input.files && input.files[0];
input.value = ''; // allow re-choosing the same file later