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:
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user