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
+29 -13
View File
@@ -782,6 +782,7 @@
transform: translateY(-2px);
}
.grid-card-thumb-wrap {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius);
@@ -822,15 +823,32 @@
font-size: 0.75em;
color: var(--text-muted);
}
.grid-card-progress-track {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
.grid-card-progress-ring {
position: absolute;
bottom: 6px;
right: 6px;
width: 26px;
height: 26px;
border-radius: 50%;
background: conic-gradient(var(--accent) calc(var(--pct) * 1%), rgba(0, 0, 0, 0.4) 0);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.grid-card-progress-fill {
height: 100%;
background: var(--accent);
.grid-card-progress-ring::before {
content: '';
position: absolute;
inset: 3px;
border-radius: 50%;
background: var(--bg-secondary);
}
.grid-card-progress-ring-check {
position: relative;
color: var(--accent);
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.transcript-search-toggle {
@@ -1704,19 +1722,17 @@
: courseInitialHtml(item.name);
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
const pct = item.completion_percentage;
const progressBar = pct
? `<div class="grid-card-progress-track"><div class="grid-card-progress-fill" style="width: ${pct}%;"></div></div>`
const ringHtml = pct
? `<div class="grid-card-progress-ring" style="--pct: ${pct};" title="${pct}% complete">${pct >= 100 ? '<span class="grid-card-progress-ring-check">✓</span>' : ''}</div>`
: '';
const metaBits = [`${item.media_files} media file${item.media_files === 1 ? '' : 's'}`];
if (item.duration_display) metaBits.push(item.duration_display);
if (pct) metaBits.push(`${pct}% done`);
return `
<div class="library-grid-card" onclick="${clickHandler}">
${selectableAttrs(item)}
<div class="grid-card-thumb-wrap">${iconHtml}</div>
<div class="grid-card-thumb-wrap">${iconHtml}${ringHtml}</div>
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
<div class="grid-card-meta">${metaBits.join(' · ')}</div>
${progressBar}
</div>
`;
}
+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) {
+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
+119
View File
@@ -687,6 +687,16 @@
<div id="dup-results"></div>
</div>
<h2 class="section-title">{{ icons.icon('clipboard', 18) }} Duplicate Lesson Files</h2>
<p class="section-desc">Scan for lesson files sitting in the same folder that look like the same lesson downloaded twice - a re-download that landed alongside the original instead of replacing it. Compares files within each folder only, with a higher match threshold than Duplicate Courses, so two genuinely different lessons on a similar topic aren't flagged.</p>
<div class="card">
<div class="toolbar">
<button class="btn btn-secondary" id="dup-lessons-scan-btn" onclick="scanDuplicateLessons()">{{ icons.icon('refresh', 14) }} Scan for Duplicate Lessons</button>
<span id="dup-lessons-status"></span>
</div>
<div id="dup-lessons-results"></div>
</div>
<h2 class="section-title">{{ icons.icon('trash', 18) }} Clean Up Stale References</h2>
<p class="section-desc">Renaming, moving, or deleting a course directly on the NAS (instead of through this app) can leave behind references in Hidden, Next Up, or Recently Viewed that point at nothing. Review and remove them here - course files on disk are never touched.</p>
<div class="card">
@@ -1933,6 +1943,115 @@
.catch(() => { btnEl.disabled = false; });
}
// ---- Duplicate Lesson Files ----
function scanDuplicateLessons() {
const btn = document.getElementById('dup-lessons-scan-btn');
const status = document.getElementById('dup-lessons-status');
const results = document.getElementById('dup-lessons-results');
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Scanning…';
results.innerHTML = '';
fetch('/api/library/duplicate-lessons')
.then(r => r.json())
.then(data => {
btn.disabled = false;
const groups = data.groups || [];
if (!groups.length) {
status.style.color = 'var(--success)';
status.textContent = 'No likely duplicate lesson files found.';
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = `${groups.length} possible duplicate group${groups.length === 1 ? '' : 's'}.`;
results.innerHTML = groups.map(group => `
<div class="dup-group">
<div class="dup-group-header">
<span>${escapeHtml(group.course_name)}${Math.round(group.similarity * 100)}% similar</span>
<button class="btn btn-secondary btn-sm" onclick="ignoreDuplicateLessonGroup(this)">Not a duplicate - ignore</button>
</div>
${group.files.map(f => `
<div class="dup-course-row" data-path="${escapeAttr(f.path)}">
<span class="dup-course-name">${escapeHtml(f.name)}</span>
<span class="dup-course-path">${escapeHtml(f.human)}</span>
<button class="btn btn-danger btn-sm" onclick="deleteDuplicateLessonFile(this, '${f.path.replace(/'/g, "\\'")}', '${f.name.replace(/'/g, "\\'")}')">Delete</button>
</div>
`).join('')}
</div>
`).join('');
})
.catch(() => {
btn.disabled = false;
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function deleteDuplicateLessonFile(btnEl, path, name) {
if (!confirm(`Permanently delete "${name}"?\n\n${path}\n\nThis cannot be undone.`)) return;
btnEl.disabled = true;
fetch('/api/library/delete-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
})
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
btnEl.disabled = false;
alert(data.error || 'Delete failed');
return;
}
const row = btnEl.closest('.dup-course-row');
const group = row ? row.closest('.dup-group') : null;
if (row) row.remove();
if (group && group.querySelectorAll('.dup-course-row').length < 2) {
group.remove();
}
refreshDupLessonsStatusText();
})
.catch(() => {
btnEl.disabled = false;
alert('Could not reach the server.');
});
}
function refreshDupLessonsStatusText() {
const status = document.getElementById('dup-lessons-status');
const remaining = document.querySelectorAll('#dup-lessons-results .dup-group').length;
if (remaining === 0) {
status.style.color = 'var(--success)';
status.textContent = 'No possible duplicates remaining.';
} else {
status.style.color = 'var(--text-muted)';
status.textContent = `${remaining} possible duplicate group${remaining === 1 ? '' : 's'} remaining.`;
}
}
function ignoreDuplicateLessonGroup(btnEl) {
const group = btnEl.closest('.dup-group');
if (!group) return;
const paths = Array.from(group.querySelectorAll('.dup-course-row')).map(el => el.dataset.path);
if (paths.length < 2) return;
if (!confirm('Mark these as NOT duplicates of each other? They won\'t be flagged together again (reversible from "Ignored matches" under Duplicate Courses).')) return;
btnEl.disabled = true;
fetch('/api/library/duplicates/ignore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths })
})
.then(r => r.json())
.then(data => {
if (data.success) {
group.remove();
refreshDupLessonsStatusText();
} else {
btnEl.disabled = false;
}
})
.catch(() => { btnEl.disabled = false; });
}
// ---- Stale References ----
let lastStaleItems = [];