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:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -1,3 +1,4 @@
|
||||
2026-08-25 00:20 UTC — Add autoplay, progress rings, duplicate-lesson detection, title-card thumbnails
|
||||
2026-08-24 23:22 UTC — Add Favorites, storage drill-down, shortcuts, and What's New
|
||||
2026-08-24 19:00 UTC — Fix video resize handle drifting off-screen
|
||||
2026-08-24 18:06 UTC — Cache the category tree walk (fixes ~1s File Management page load)
|
||||
|
||||
@@ -101,7 +101,11 @@ silently stay blank instead of erroring.
|
||||
completion % at a glance
|
||||
- Cover art: uses a manually-placed `cover`/`folder`/`thumbnail`/`thumb`/
|
||||
`poster` image if a course has one, otherwise auto-generates one via
|
||||
`ffmpeg` from a frame of the course's first video
|
||||
`ffmpeg` - samples a few candidate frames from the first several seconds
|
||||
of the course's first video (where an intro title card typically lives)
|
||||
and keeps the one that compresses to the largest JPEG, a cheap proxy for
|
||||
"has the most going on" that favors a title card/logo over a blank
|
||||
fade-in or a plain frame of the presenter
|
||||
- **File Management** ([/unsorted](templates/unsorted.html), its own item
|
||||
("Files") in the bottom tab bar alongside Home/Notes/Help/Settings): a
|
||||
dedicated page for everything that touches files on disk, since it needs
|
||||
@@ -181,13 +185,20 @@ silently stay blank instead of erroring.
|
||||
difference doesn't hide a real duplicate. Groups by similarity (union of
|
||||
any two courses over the threshold, transitively) rather than showing
|
||||
raw pairs; each course in a group gets a one-click Hide, or a **Delete**
|
||||
that permanently removes it from disk (the one destructive action in the
|
||||
whole app - confirmed with the full path before it runs). A group can
|
||||
also be marked **"Not a duplicate"** if the match is wrong, which
|
||||
excludes that specific pair from future scans without touching anything
|
||||
else that happens to match one of those courses; ignored pairs are
|
||||
listed (and reversible) under "Ignored matches," and persist through
|
||||
backup/restore alongside hidden paths and Next Up.
|
||||
that permanently removes it from disk (confirmed with the full path
|
||||
before it runs). A group can also be marked **"Not a duplicate"** if the
|
||||
match is wrong, which excludes that specific pair from future scans
|
||||
without touching anything else that happens to match one of those
|
||||
courses; ignored pairs are listed (and reversible) under "Ignored
|
||||
matches," and persist through backup/restore alongside hidden paths and
|
||||
Next Up.
|
||||
- *Duplicate Lesson Files*: the same idea one level down - media files
|
||||
sitting in the same folder that look like the same lesson downloaded
|
||||
twice (e.g. a re-download that landed alongside the original instead of
|
||||
replacing it). Compares only within a folder, at a higher match
|
||||
threshold than Duplicate Courses, so two different lessons on a similar
|
||||
topic don't get flagged; shares its ignore list with Duplicate Courses.
|
||||
Delete here removes a single file, not a whole course.
|
||||
- *Clean Up Stale References*: finds entries in the hidden-paths list,
|
||||
Next Up queue, Favorites, or Recently Viewed history that point at a path
|
||||
no longer on disk - normally from renaming/moving/deleting a course
|
||||
@@ -210,10 +221,15 @@ silently stay blank instead of erroring.
|
||||
- Recently Added / Recently Viewed
|
||||
- Surprise Me — dice icon in the header, random pick weighted toward
|
||||
incomplete courses
|
||||
- Grid view shows a small progress ring in the corner of each course's
|
||||
thumbnail (a checkmark once complete) instead of a separate bar, so
|
||||
completion reads at a glance without switching to list view
|
||||
|
||||
**Playback & progress**
|
||||
- Video/audio player with resize, playback-speed presets, and resume-from-
|
||||
last-position
|
||||
- Auto-play next lesson when one ends, with a cancelable few-second
|
||||
countdown - on by default, toggle it off in Settings → Video Player
|
||||
- Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek
|
||||
10s), ↑/↓ (volume), `,`/`.` (step playback speed), `[`/`]` (previous/next
|
||||
lesson), `F` (fullscreen), `N` (quick-capture a note) - see Help for the
|
||||
@@ -226,6 +242,11 @@ silently stay blank instead of erroring.
|
||||
- Settings → "Precompute Lengths & Cover Art" walks the whole library in
|
||||
the background to populate durations and thumbnails up front, with live
|
||||
progress
|
||||
- Settings → "Regenerate Thumbnails" deletes and re-runs auto-generated
|
||||
cover art for every course that has one (manually-placed covers are
|
||||
never touched) - the only way to pick up an improvement to how
|
||||
thumbnails are picked on courses whose thumbnail was already cached
|
||||
under the old logic, since it's otherwise served from disk forever
|
||||
|
||||
**Notes**
|
||||
- Timestamped notes per lesson, capturable via a keyboard shortcut without
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026-08-24 23:22 UTC — add Favorites, storage drill-down, shortcuts, and What's New
|
||||
2026-08-25 00:20 UTC — autoplay, progress rings, duplicate lessons, title-card thumbnails
|
||||
|
||||
+210
-17
@@ -164,6 +164,7 @@ DEFAULT_SETTINGS = {
|
||||
'video_width': '', # '' = responsive full-width; else last dragged size, in px
|
||||
'video_height': '',
|
||||
'playback_speed': '1', # video/audio playback rate, as a string (see SETTINGS_CHOICES)
|
||||
'autoplay_next': True, # advance to the next lesson automatically when one ends
|
||||
}
|
||||
|
||||
# Bounds for the persisted video player size, to reject garbage values
|
||||
@@ -551,6 +552,8 @@ def load_settings() -> Dict[str, Any]:
|
||||
elif key in VIDEO_SIZE_BOUNDS:
|
||||
if value == '' or (isinstance(value, int) and not isinstance(value, bool)):
|
||||
settings[key] = value
|
||||
elif key == 'autoplay_next' and isinstance(value, bool):
|
||||
settings[key] = value
|
||||
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
|
||||
settings[key] = value
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
@@ -588,6 +591,8 @@ def save_settings(new_settings: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not (lo <= num <= hi):
|
||||
raise ValueError(f"{key} must be between {lo} and {hi}")
|
||||
current[key] = num
|
||||
elif key == 'autoplay_next' and isinstance(value, bool):
|
||||
current[key] = value
|
||||
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
|
||||
current[key] = value
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
@@ -913,13 +918,23 @@ def find_course_thumbnail(course_path: str) -> Optional[str]:
|
||||
|
||||
def _generate_course_thumbnail(course_dir: Path) -> Optional[str]:
|
||||
"""
|
||||
Grab a single frame from the course's first video as stand-in cover
|
||||
art, via ffmpeg, cached to .offlineu_thumbnail.jpg so this only ever
|
||||
runs once per course - the next call finds that file directly through
|
||||
the fast path above instead of hitting this function again. Returns
|
||||
None (nothing cached to disk) if the course has no video at all, so a
|
||||
video added later is still picked up on the next request past the
|
||||
short in-memory cache above.
|
||||
Grab a frame from the course's first video as stand-in cover art, via
|
||||
ffmpeg, cached to .offlineu_thumbnail.jpg so this only ever runs once
|
||||
per course - the next call finds that file directly through the fast
|
||||
path above instead of hitting this function again. Returns None
|
||||
(nothing cached to disk) if the course has no video at all, so a video
|
||||
added later is still picked up on the next request past the short
|
||||
in-memory cache above.
|
||||
|
||||
Course intros typically show a title card (course/lesson name, maybe
|
||||
a logo) for the first several seconds before cutting to the
|
||||
presenter - a single frame at a fixed offset can easily land past
|
||||
that cut and grab someone mid-sentence instead. Rather than guess one
|
||||
offset, this samples a handful of candidates in the first ~8 seconds
|
||||
and keeps the one with the largest resulting JPEG: a title card (text,
|
||||
graphics, a logo) compresses to a noticeably bigger file than a blank
|
||||
fade-in or a plain talking-head frame, which is a cheap enough proxy
|
||||
for "has more going on" without any real image analysis.
|
||||
"""
|
||||
video_files = sorted(
|
||||
f for f in course_dir.rglob('*')
|
||||
@@ -930,19 +945,43 @@ def _generate_course_thumbnail(course_dir: Path) -> Optional[str]:
|
||||
source = video_files[0]
|
||||
|
||||
duration = _probe_media_duration_seconds(source) or 0
|
||||
offset = min(30.0, duration * 0.1) if duration else 5.0
|
||||
max_offset = min(8.0, duration * 0.5) if duration else 6.0
|
||||
candidate_offsets = sorted({o for o in (1.0, 2.5, 4.0, 6.0) if o <= max_offset}) or [1.0]
|
||||
|
||||
output_path = course_dir / AUTO_THUMBNAIL_FILENAME
|
||||
best_candidate = None
|
||||
best_size = -1
|
||||
candidate_paths = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ffmpeg', '-y', '-ss', str(offset), '-i', str(source),
|
||||
'-frames:v', '1', '-vf', 'scale=480:-1', '-q:v', '3', str(output_path)],
|
||||
capture_output=True, timeout=20
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if result.returncode != 0 or not output_path.exists():
|
||||
return None
|
||||
for i, offset in enumerate(candidate_offsets):
|
||||
candidate_path = course_dir / f'.offlineu_thumbnail_candidate_{i}.jpg'
|
||||
candidate_paths.append(candidate_path)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ffmpeg', '-y', '-ss', str(offset), '-i', str(source),
|
||||
'-frames:v', '1', '-vf', 'scale=480:-1', '-q:v', '3', str(candidate_path)],
|
||||
capture_output=True, timeout=20
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
if result.returncode != 0 or not candidate_path.exists():
|
||||
continue
|
||||
size = candidate_path.stat().st_size
|
||||
if size > best_size:
|
||||
best_size = size
|
||||
best_candidate = candidate_path
|
||||
|
||||
if best_candidate is None:
|
||||
return None
|
||||
shutil.move(str(best_candidate), str(output_path))
|
||||
finally:
|
||||
for candidate_path in candidate_paths:
|
||||
if candidate_path.exists():
|
||||
try:
|
||||
candidate_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return str(output_path)
|
||||
|
||||
|
||||
@@ -1814,6 +1853,64 @@ def _find_duplicate_courses(min_similarity: float = 0.6) -> List[Dict[str, Any]]
|
||||
return groups
|
||||
|
||||
|
||||
def _find_duplicate_lesson_files(min_similarity: float = 0.75) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Within each course, media files sitting in the same folder whose
|
||||
names look like the same lesson downloaded twice - e.g. "03 Setting
|
||||
Up Your Environment.mp4" and "03 Setting Up Your Environment (1).mp4"
|
||||
left behind by a re-download that landed in the same section. Scoped
|
||||
to siblings in the same folder, unlike Duplicate Courses' whole-library
|
||||
comparison: two genuinely different lessons can share a lot of
|
||||
vocabulary ("Part 1"/"Part 2" of a topic) without being duplicates, so
|
||||
same-folder pairing plus a higher similarity threshold keeps this
|
||||
conservative. Shares Duplicate Courses' ignore list (get_ignored_
|
||||
duplicate_pairs) - a pair is just a pair of paths there, whether
|
||||
they're course folders or files. Read-only.
|
||||
"""
|
||||
ignored_pairs = get_ignored_duplicate_pairs()
|
||||
groups = []
|
||||
for course_dir in get_all_course_dirs():
|
||||
by_folder: Dict[Path, List[Path]] = {}
|
||||
for f in course_dir.rglob('*'):
|
||||
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
|
||||
by_folder.setdefault(f.parent, []).append(f)
|
||||
|
||||
for files in by_folder.values():
|
||||
if len(files) < 2:
|
||||
continue
|
||||
entries = [{'path': f, 'tokens': _tokenize(f.stem)} for f in files]
|
||||
n = len(entries)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
path_i, path_j = str(entries[i]['path']), str(entries[j]['path'])
|
||||
if tuple(sorted((path_i, path_j))) in ignored_pairs:
|
||||
continue
|
||||
a, b = entries[i]['tokens'], entries[j]['tokens']
|
||||
union_tokens = a | b
|
||||
if not union_tokens:
|
||||
continue
|
||||
similarity = len(a & b) / len(union_tokens)
|
||||
if similarity < min_similarity:
|
||||
continue
|
||||
try:
|
||||
size_i = entries[i]['path'].stat().st_size
|
||||
size_j = entries[j]['path'].stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
groups.append({
|
||||
'course_name': course_dir.name,
|
||||
'course_path': str(course_dir),
|
||||
'files': sorted([
|
||||
{'path': path_i, 'name': entries[i]['path'].name, 'bytes': size_i, 'human': _format_bytes(size_i)},
|
||||
{'path': path_j, 'name': entries[j]['path'].name, 'bytes': size_j, 'human': _format_bytes(size_j)},
|
||||
], key=lambda f: f['name'].lower()),
|
||||
'similarity': round(similarity, 2),
|
||||
})
|
||||
|
||||
groups.sort(key=lambda g: g['similarity'], reverse=True)
|
||||
return groups
|
||||
|
||||
|
||||
def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
|
||||
"""
|
||||
Entries in hidden_paths.json / next_up.json / recent_views.json that
|
||||
@@ -3652,6 +3749,47 @@ def undo_last_action_api():
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/library/duplicate-lessons')
|
||||
def duplicate_lesson_files_api():
|
||||
"""Scan for lesson files within the same course/folder that look like
|
||||
the same lesson downloaded twice (see _find_duplicate_lesson_files).
|
||||
Manual trigger, same reasoning as Duplicate Courses."""
|
||||
groups = _find_duplicate_lesson_files()
|
||||
return jsonify({'groups': groups})
|
||||
|
||||
|
||||
@app.route('/api/library/delete-file', methods=['POST'])
|
||||
def delete_library_file_api():
|
||||
"""
|
||||
Permanently delete a single media file from disk - the file-level
|
||||
counterpart to /api/library/delete, used by Duplicate Lesson Files.
|
||||
Scoped to files (not directories) inside the library root with a
|
||||
recognized media extension, so it can't be pointed at something else.
|
||||
"""
|
||||
data = request.json or {}
|
||||
path = (data.get('path') or '').strip()
|
||||
if not path:
|
||||
return jsonify({'success': False, 'error': 'Missing path'}), 400
|
||||
|
||||
library_root = os.path.abspath(get_library_root())
|
||||
target_abs = os.path.abspath(path)
|
||||
|
||||
if not target_abs.startswith(library_root + os.sep):
|
||||
return jsonify({'success': False, 'error': 'Path is outside the library'}), 403
|
||||
if not os.path.isfile(target_abs):
|
||||
return jsonify({'success': False, 'error': 'No longer exists - already deleted?'}), 404
|
||||
if Path(target_abs).suffix.lower() not in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS:
|
||||
return jsonify({'success': False, 'error': 'Not a media file'}), 400
|
||||
|
||||
try:
|
||||
os.remove(target_abs)
|
||||
except OSError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
invalidate_cache()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/library/duplicates')
|
||||
def duplicate_courses_api():
|
||||
"""Scan for courses that look like copies of each other by name (see
|
||||
@@ -3849,6 +3987,61 @@ def prewarm_durations_status_api():
|
||||
return jsonify(_duration_prewarm_state)
|
||||
|
||||
|
||||
# Same single-process-thread pattern as the duration prewarm above.
|
||||
_thumbnail_regen_state: Dict[str, Any] = {
|
||||
'running': False,
|
||||
'total': 0,
|
||||
'done': 0,
|
||||
'error': None,
|
||||
}
|
||||
|
||||
|
||||
def _run_thumbnail_regen():
|
||||
"""
|
||||
Delete and regenerate every course's auto-generated thumbnail
|
||||
(.offlineu_thumbnail.jpg) using _generate_course_thumbnail's current
|
||||
logic - the only way to pick up an improved generation heuristic (e.g.
|
||||
the title-card sampling added after thumbnails were already cached)
|
||||
on courses whose thumbnail was generated before that change, since
|
||||
find_course_thumbnail's normal fast path just keeps serving whatever
|
||||
is already cached on disk forever. Only touches courses currently
|
||||
using an auto-generated thumbnail - a manually-placed cover/folder/
|
||||
thumbnail/thumb/poster file always wins and is never removed.
|
||||
"""
|
||||
global _thumbnail_regen_state
|
||||
to_regen = [c for c in get_all_course_dirs() if (c / AUTO_THUMBNAIL_FILENAME).exists()]
|
||||
_thumbnail_regen_state.update({
|
||||
'running': True, 'total': len(to_regen), 'done': 0, 'error': None,
|
||||
})
|
||||
try:
|
||||
for course_dir in to_regen:
|
||||
try:
|
||||
(course_dir / AUTO_THUMBNAIL_FILENAME).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
_generate_course_thumbnail(course_dir)
|
||||
_thumbnail_regen_state['done'] += 1
|
||||
except Exception as e:
|
||||
_thumbnail_regen_state['error'] = str(e)
|
||||
finally:
|
||||
_thumbnail_regen_state['running'] = False
|
||||
invalidate_cache()
|
||||
|
||||
|
||||
@app.route('/api/library/regenerate-thumbnails', methods=['POST'])
|
||||
def regenerate_thumbnails_api():
|
||||
"""Kick off (or report on an already-running) background regeneration of every auto-generated course thumbnail."""
|
||||
if not _thumbnail_regen_state['running']:
|
||||
threading.Thread(target=_run_thumbnail_regen, daemon=True).start()
|
||||
return jsonify(_thumbnail_regen_state)
|
||||
|
||||
|
||||
@app.route('/api/library/regenerate-thumbnails/status', methods=['GET'])
|
||||
def regenerate_thumbnails_status_api():
|
||||
"""Poll the current progress of a thumbnail regeneration run."""
|
||||
return jsonify(_thumbnail_regen_state)
|
||||
|
||||
|
||||
@app.route('/api/hidden-paths', methods=['GET'])
|
||||
def get_hidden_paths_api():
|
||||
"""List currently-hidden course/directory paths, with display names."""
|
||||
|
||||
@@ -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