Add search, thumbnails, continue watching, playback speed, PWA install, sort, notes, and mark-watched

Eight usability additions on top of the mobile redesign: course cover-image
thumbnails, a debounced library search, a Continue Watching section split
from Recently Viewed, per-lesson playback speed control, an installable
PWA manifest/icons, name sort in the library browser, per-lesson notes, and
a bulk mark-course-as-watched action. Also fixes two real bugs found along
the way: lesson_view.html was missing the viewport meta tag (so lesson
pages weren't actually mobile-responsive), and update_lesson_progress
always overwrote the whole progress entry, which would have silently
deleted a saved note on the next routine autosave.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 09:06:44 -04:00
co-authored by Claude Sonnet 5
parent c92268e04c
commit 38963b1d55
9 changed files with 597 additions and 51 deletions
+174 -5
View File
@@ -25,6 +25,8 @@ AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'}
SUBTITLE_EXTENSIONS = {'.srt', '.vtt', '.ass', '.sub', '.sbv'}
TEXT_EXTENSIONS = {'.txt', '.md', '.html', '.htm', '.pdf', '.docx', '.doc', '.rtf'}
QUIZ_INDICATORS = {'quiz', 'exam', 'test', 'assessment', 'exercise', 'assignment', 'homework'}
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
THUMBNAIL_BASENAMES = ('cover', 'folder', 'thumbnail', 'thumb', 'poster')
# Base directory the "Library" browser scans for courses, so users don't have
# to type a full filesystem path. Matches the ./courses volume mount in
@@ -49,6 +51,7 @@ DEFAULT_SETTINGS = {
'library_path': '', # '' = use COURSES_LIBRARY_PATH/--library-path default
'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)
}
# Bounds for the persisted video player size, to reject garbage values
@@ -68,6 +71,7 @@ SETTINGS_CHOICES = {
'density': {'comfortable', 'compact'},
'card_style': {'flat', 'elevated', 'bordered'},
'corner_radius': {'sharp', 'rounded', 'pill'},
'playback_speed': {'0.75', '1', '1.25', '1.5', '1.75', '2'},
}
# Display names for the theme dropdown - only needed where the raw key
@@ -485,6 +489,24 @@ def _has_direct_media(directory: Path) -> bool:
return False
def find_course_thumbnail(course_path: str) -> Optional[str]:
"""
Look for a cover image directly inside a course folder (not recursive -
this only needs to catch the common 'cover.jpg next to the sections'
layout, not go hunting through every subfolder).
"""
try:
names = {f.name.lower(): f for f in Path(course_path).iterdir() if f.is_file()}
except (PermissionError, OSError):
return None
for base in THUMBNAIL_BASENAMES:
for ext in IMAGE_EXTENSIONS:
match = names.get(f'{base}{ext}')
if match:
return str(match)
return None
_SECTION_NAME_RE = re.compile(
r'^(section|module|chapter|part|unit|lesson)\b', re.IGNORECASE
)
@@ -672,7 +694,8 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str,
'name': entry.name,
'path': entry_path_str,
'media_files': media_count,
'hidden': is_hidden
'hidden': is_hidden,
'has_thumbnail': find_course_thumbnail(entry_path_str) is not None
})
else:
if skip_hidden:
@@ -709,8 +732,31 @@ def get_library_root() -> str:
return LIBRARY_PATH
def search_library_courses(dir_path: str, query: str) -> List[Dict[str, Any]]:
"""
Recursively search the library for courses whose name contains `query`
(case-insensitive). Walks via list_library_directory, so it reuses the
exact same course/directory detection and hidden-path filtering as
normal browsing - a hidden course or folder never shows up in results.
"""
query_lower = query.lower()
results: List[Dict[str, Any]] = []
def walk(path: str):
level = list_library_directory(path)
for item in level['items']:
if item['type'] == 'course':
if query_lower in item['name'].lower():
results.append(item)
else:
walk(item['path'])
walk(dir_path)
return results
RECENT_VIEWS_FILE = os.path.join(DATA_DIR, 'recent_views.json')
MAX_RECENT_VIEWS = 5
MAX_RECENT_VIEWS = 20
def record_recent_view(course_name: str, course_path: str, lesson_path: str, lesson_title: str) -> None:
@@ -797,6 +843,8 @@ def get_recent_views_for_display() -> List[Dict[str, Any]]:
entry['percent_watched'] = max(0, min(100, round(100 * progress_seconds / duration_seconds)))
else:
entry['percent_watched'] = 0
entry['has_thumbnail'] = find_course_thumbnail(entry.get('course_path', '')) is not None
return entries
@@ -840,11 +888,47 @@ class ProgressTracker:
elif existing.get('duration_seconds'):
entry['duration_seconds'] = existing['duration_seconds']
# Same for a note - this call has no opinion on it, so don't let a
# routine playback-progress save wipe one out.
if existing.get('note'):
entry['note'] = existing['note']
progress[lesson_path] = entry
# Update last accessed path
progress['last_accessed_path'] = lesson_path
ProgressTracker.save_progress(course, progress)
@staticmethod
def update_lesson_note(course: Course, lesson_path: str, note: str):
"""Save (or clear) a lesson's note, without touching its playback progress."""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
if note:
entry['note'] = note
else:
entry.pop('note', None)
ProgressTracker.save_progress(course, progress)
@staticmethod
def mark_all_completed(course: Course):
"""Mark every lesson in the course as completed, in one save."""
progress = ProgressTracker.load_progress(course)
def mark_node(node: DirectoryNode):
for lesson in node.lessons:
lesson_path = os.path.relpath(lesson.path, course.path).replace('\\', '/')
if lesson_path.startswith('/'):
lesson_path = lesson_path[1:]
entry = progress.setdefault(lesson_path, {})
entry['completed'] = True
entry['last_accessed'] = datetime.now().isoformat()
entry.setdefault('progress_seconds', entry.get('duration_seconds', 0))
for child in node.children.values():
mark_node(child)
mark_node(course.root_node)
ProgressTracker.save_progress(course, progress)
@staticmethod
@@ -891,17 +975,37 @@ class ProgressTracker:
current_course = None
def _split_continue_watching(all_views: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Split the recent-views list (already carrying percent_watched/completed,
see get_recent_views_for_display) into 'Continue Watching' - genuinely
in-progress lessons - and the remaining 'Recently Viewed' entries, each
capped at 5 for the dashboard. Reuses the same underlying history rather
than a separate library-wide progress index (see plan notes).
"""
continue_watching = [v for v in all_views if 0 < v['percent_watched'] < 100][:5]
continue_ids = {(v.get('course_path'), v.get('lesson_path')) for v in continue_watching}
recent_views = [
v for v in all_views
if (v.get('course_path'), v.get('lesson_path')) not in continue_ids
][:5]
return continue_watching, recent_views
@app.route('/')
def index():
"""Main dashboard"""
global current_course
continue_watching, recent_views = _split_continue_watching(get_recent_views_for_display())
if current_course is None:
# Show dashboard with course selection option
return render_template('course_dashboard.html',
course=None,
stats={'total_lessons': 0, 'completed_lessons': 0, 'completion_percentage': 0},
recent_views=get_recent_views_for_display())
continue_watching=continue_watching,
recent_views=recent_views)
# Apply progress data to tree
ProgressTracker.apply_progress_to_tree(current_course)
@@ -910,7 +1014,8 @@ def index():
return render_template('course_dashboard.html',
course=current_course,
stats=stats,
recent_views=get_recent_views_for_display())
continue_watching=continue_watching,
recent_views=recent_views)
@app.route('/browse')
@@ -1048,6 +1153,34 @@ def browse_library_manage():
})
@app.route('/library/thumbnail')
def library_thumbnail():
"""Serve a course's cover image (see find_course_thumbnail), if it has one."""
library_root = os.path.abspath(get_library_root())
course_path = request.args.get('path', '')
target_path = os.path.abspath(course_path)
if not (target_path == library_root or target_path.startswith(library_root + os.sep)):
return '', 403
thumbnail = find_course_thumbnail(target_path)
if not thumbnail:
return '', 404
return send_file(thumbnail)
@app.route('/library/search')
def library_search():
"""Recursively search course names in the library (respects hidden paths)."""
query = request.args.get('q', '').strip()
library_root = os.path.abspath(get_library_root())
if not query:
return jsonify({'library_path': library_root, 'results': []})
results = search_library_courses(library_root, query)
return jsonify({'library_path': library_root, 'results': results})
@app.route('/api/hidden-paths', methods=['GET'])
def get_hidden_paths_api():
"""List currently-hidden course/directory paths, with display names."""
@@ -1276,10 +1409,16 @@ def view_lesson(lesson_path: str):
# Record for the cross-course "Recently Viewed" list on the dashboard
record_recent_view(current_course.name, current_course.path, lesson_path, lesson.title)
# Read the note directly from the progress file rather than the Lesson
# object - apply_progress_to_tree (which populates Lesson fields) isn't
# called on this code path, only on the dashboard's tree render.
note = ProgressTracker.load_progress(current_course).get(lesson_path, {}).get('note', '')
return render_template('lesson_view.html',
course=current_course,
lesson=lesson,
lesson_path=lesson_path,
lesson_note=note,
prev_lesson=prev_lesson,
next_lesson=next_lesson)
@@ -1367,6 +1506,36 @@ def update_progress():
return jsonify({'error': str(e)}), 500
@app.route('/api/lesson-note', methods=['POST'])
def update_lesson_note_api():
"""API endpoint to save (or clear) a lesson's note"""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
data = request.json or {}
lesson_path = data.get('lesson_path')
note = (data.get('note') or '').strip()
if not lesson_path:
return jsonify({'error': 'lesson_path is required'}), 400
ProgressTracker.update_lesson_note(current_course, lesson_path, note)
return jsonify({'success': True})
@app.route('/api/course/mark-watched', methods=['POST'])
def mark_course_watched_api():
"""Mark every lesson in the currently loaded course as completed."""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
ProgressTracker.mark_all_completed(current_course)
return jsonify({'success': True})
@app.route('/files/<path:filepath>')
def serve_file(filepath):
"""Serve course files"""