From 38963b1d550429d11d44e3a6f40b8daa9761e401 Mon Sep 17 00:00:00 2001 From: rmsitz Date: Sat, 22 Aug 2026 09:06:44 -0400 Subject: [PATCH] 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 --- OfflineU-project-summary.md | 40 +++++ offlineu_core.py | 179 ++++++++++++++++++++++- static/icons/icon-192.png | Bin 0 -> 3485 bytes static/icons/icon-512.png | Bin 0 -> 9975 bytes static/manifest.json | 23 +++ templates/course_dashboard.html | 250 ++++++++++++++++++++++++++------ templates/help.html | 3 + templates/lesson_view.html | 140 +++++++++++++++++- templates/settings.html | 13 ++ 9 files changed, 597 insertions(+), 51 deletions(-) create mode 100644 static/icons/icon-192.png create mode 100644 static/icons/icon-512.png create mode 100644 static/manifest.json diff --git a/OfflineU-project-summary.md b/OfflineU-project-summary.md index 919ccac..4384457 100644 --- a/OfflineU-project-summary.md +++ b/OfflineU-project-summary.md @@ -123,6 +123,46 @@ built locally from a private Gitea repo rather than pulling the upstream image. it). If you rename your *currently loaded* course's folder, the app resets to the library view rather than serving a stale path. +## Eight usability features (this session) + +Asked for after brainstorming what could make the app nicer to use — see +`.claude/plans/dazzling-yawning-glacier.md` for the full scoping rationale. + +1. **Course thumbnails** — `find_course_thumbnail()` looks for + `cover`/`folder`/`thumbnail`/`thumb`/`poster` (`.jpg/.jpeg/.png/.webp`) + directly inside a course folder; served via `GET /library/thumbnail`. + Shown in the Library browser and Recently Viewed/Continue Watching, + falling back to the emoji icon if there's no cover image. +2. **Library search** — `GET /library/search?q=...` recursively walks the + library (reusing `list_library_directory`'s hidden-path filtering) and + matches on course name. Debounced search box above the Library browser. +3. **Continue Watching** — reuses the Recently-Viewed history + (`MAX_RECENT_VIEWS` bumped 5→20) rather than a full library-wide progress + index; split into "in progress" vs. "recently touched" on the dashboard. +4. **Playback speed control** — `settings.playback_speed` (0.75x–2x), + persisted the same way video player size already was; speed buttons on + the lesson page, applied on load via the existing `/api/settings` fetch. +5. **Installable app (PWA)** — `static/manifest.json` + + `static/icons/icon-{192,512}.png`, linked from every template's ``. + Install-only, deliberately **no service worker/offline caching** — this + app has no real "offline" mode (it's a thin client over the Flask + backend/NAS files), so a caching SW would just create stale-content bugs. + Also fixed: `lesson_view.html` was missing the viewport meta tag entirely, + so lesson/video pages weren't actually mobile-responsive until now. +6. **Sort in the Library browser** — client-side Name A→Z/Z→A only; + progress-based filtering (not-started/in-progress/done) was scoped out, + since it'd need the same expensive per-course scan as #3's "ideal" version. +7. **Per-lesson notes** — a `note` field alongside `completed`/ + `progress_seconds` in each lesson's existing progress-file entry, via + `ProgressTracker.update_lesson_note()` / `POST /api/lesson-note`. Fixed a + real bug in `update_lesson_progress` while at it: it always overwrote the + entire lesson entry, which would have silently deleted a saved note on + the next routine playback-progress autosave. +8. **Mark course as watched** — `POST /api/course/mark-watched`, scoped to + whatever course is currently loaded (not an arbitrary library path, to + avoid re-validating/re-scanning an untrusted path). Button lives in the + course stats card, behind a confirm prompt. + ## Known limitations still open - App is unauthenticated by design (matches upstream) — settings and hidden-path curation apply app-wide, not per-browser/per-user. diff --git a/offlineu_core.py b/offlineu_core.py index d225f33..e0c0b74 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -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/') def serve_file(filepath): """Serve course files""" diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..6b2ab1458608a41b39a26158c5767ad99d4dfda5 GIT binary patch literal 3485 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 E0La|`xBvhE literal 0 HcmV?d00001 diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..aa9f02f --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "OfflineU", + "short_name": "OfflineU", + "description": "Your self-hosted course library", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a1a", + "theme_color": "#007acc", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 14717d6..dd5d088 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -4,6 +4,9 @@ {% if course %}{{ course.name }} - OfflineU{% else %}OfflineU{% endif %} + + +