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 0000000..6b2ab14 Binary files /dev/null and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000..94f8d85 Binary files /dev/null and b/static/icons/icon-512.png differ 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 %} + + +