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
+60 -1
View File
@@ -164,6 +164,17 @@
color: var(--text-muted);
margin-top: 6px;
}
.autoplay-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 10px;
padding: 10px 14px;
background: var(--bg-tertiary);
border-radius: var(--radius);
font-size: 0.9em;
}
.speed-controls {
display: flex;
align-items: center;
@@ -539,6 +550,13 @@
<p class="resize-hint">↘ Drag the bottom-right corner to resize the player</p>
{% endif %}
{% if next_lesson %}
<div class="autoplay-banner" id="autoplay-banner" style="display: none;">
<span>Playing next lesson in <span id="autoplay-countdown">5</span>s…</span>
<button type="button" class="btn btn-secondary" onclick="cancelAutoplay()">Cancel</button>
</div>
{% endif %}
{% if lesson.audio_file %}
<h3>Audio</h3>
<audio controls preload="metadata" id="audio-player">
@@ -800,13 +818,16 @@
// Restore the persisted playback speed and highlight the active
// button; clicking a button applies it immediately and persists
// it via /api/settings, mirroring the video-size pattern above.
let autoplayNextEnabled = true;
if (activeMedia) {
fetch('/api/settings')
.then(r => r.json())
.then(data => {
const speed = (data.settings || {}).playback_speed || '1';
const settings = data.settings || {};
const speed = settings.playback_speed || '1';
activeMedia.playbackRate = parseFloat(speed);
highlightSpeedButton(speed);
autoplayNextEnabled = settings.autoplay_next !== false;
})
.catch(() => {});
}
@@ -827,6 +848,8 @@
}).catch(() => {});
}
const NEXT_LESSON_URL = {{ ('/lesson/' ~ next_lesson)|tojson if next_lesson else 'null' }};
// ---- Outline topic chooser + push-on-leave ----
const LESSON_PATH = {{ lesson_path|tojson }};
const LESSON_TITLE = {{ lesson.title|tojson }};
@@ -1210,7 +1233,43 @@
activeMedia.addEventListener('ended', function() {
saveProgress(activeMedia.currentTime, true);
markAsCompleted();
if (NEXT_LESSON_URL && autoplayNextEnabled) startAutoplayCountdown();
});
// Replaying the just-ended lesson (rather than letting it sit)
// is a clear enough signal the user doesn't want to leave yet.
activeMedia.addEventListener('play', cancelAutoplay);
}
let autoplayTimer = null;
let autoplaySecondsLeft = 5;
function startAutoplayCountdown() {
const banner = document.getElementById('autoplay-banner');
const countdownEl = document.getElementById('autoplay-countdown');
if (!banner || !countdownEl) return;
autoplaySecondsLeft = 5;
countdownEl.textContent = autoplaySecondsLeft;
banner.style.display = 'flex';
autoplayTimer = setInterval(function() {
autoplaySecondsLeft -= 1;
if (autoplaySecondsLeft <= 0) {
clearInterval(autoplayTimer);
autoplayTimer = null;
window.location.href = NEXT_LESSON_URL;
return;
}
countdownEl.textContent = autoplaySecondsLeft;
}, 1000);
}
function cancelAutoplay() {
if (autoplayTimer) {
clearInterval(autoplayTimer);
autoplayTimer = null;
}
const banner = document.getElementById('autoplay-banner');
if (banner) banner.style.display = 'none';
}
function saveProgress(progressSeconds, completed = false) {