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:
+174
-5
@@ -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"""
|
||||
|
||||
Reference in New Issue
Block a user