From fc83876abef50123ed9d77bca4399a713caac492 Mon Sep 17 00:00:00 2001 From: rmsitz Date: Mon, 24 Aug 2026 19:23:40 -0400 Subject: [PATCH] Add Favorites, storage drill-down, keyboard shortcuts, and What's New - Favorites: star toggle on course rows, the course page, and a new dashboard card; rebased on rename/move, backed up/restored, and caught by stale-reference cleanup (also fixed a pre-existing gap where favorites.json wasn't in the backup file list). - Storage Usage: click a folder row to drill into its contents one level at a time, with a Back button. - Lesson player: added ,/. (speed step), [/] (prev/next lesson), and F (fullscreen) keyboard shortcuts. - Settings: a CHANGELOG.md-backed "What's New" list, since VERSION alone only ever shows the current build. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 13 +++ README.md | 32 ++++-- VERSION | 2 +- offlineu_core.py | 192 ++++++++++++++++++++++++++++---- templates/_icons.html | 2 +- templates/course_dashboard.html | 101 +++++++++++++++++ templates/help.html | 3 + templates/lesson_view.html | 47 ++++++++ templates/settings.html | 11 ++ templates/unsorted.html | 40 ++++++- 10 files changed, 404 insertions(+), 39 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0f81cc4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +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) +2026-08-24 14:41 UTC — Add search, bulk actions, undo, and storage usage to File Management +2026-08-24 14:00 UTC — Fix build version showing "unknown" in production +2026-08-24 13:52 UTC — Fix wrapped-course false positive in picker; add build version display +2026-08-24 13:21 UTC — Exclude media-free subtrees from the destination picker too +2026-08-24 13:13 UTC — Exclude course/item leaves from destination picker; ease new-folder UX +2026-08-24 11:45 UTC — Add wildcard and regex modes to Bulk Rename +2026-08-24 11:11 UTC — Add delete and ignore actions to Duplicate Courses +2026-08-24 10:37 UTC — Move File Management link into the bottom tab bar +2026-08-24 10:29 UTC — Tighten Sort Unsorted matching; consolidate file management tools +2026-08-24 09:10 UTC — Add Unsorted folder auto-sort with keyword matching diff --git a/README.md b/README.md index 53662fb..9f25281 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,10 @@ guessing. Deliberately not derived from `git rev-parse` at Docker build time - Dockhand's build context doesn't reliably have `.git` available, which silently produced "unknown" instead of an actual commit. Reads as `dev` outside Docker (no `VERSION` file to read, e.g. before the first -commit that adds one). +commit that adds one). `CHANGELOG.md` at the repo root accumulates that +same line on every commit (newest first) instead of overwriting it, and +shows as a "What's New" list under the version line in Settings - so you +can see recent history, not just the current build. --- @@ -186,18 +189,22 @@ silently stay blank instead of erroring. listed (and reversible) under "Ignored matches," and persist through backup/restore alongside hidden paths and Next Up. - *Clean Up Stale References*: finds entries in the hidden-paths list, - Next Up queue, or Recently Viewed history that point at a path no longer - on disk - normally from renaming/moving/deleting a course directly on - the NAS instead of through the app (doing it through the app already - keeps these in sync). Review individually or clear them all at once; - course files themselves are never touched. + Next Up queue, Favorites, or Recently Viewed history that point at a path + no longer on disk - normally from renaming/moving/deleting a course + directly on the NAS instead of through the app (doing it through the app + already keeps these in sync). Review individually or clear them all at + once; course files themselves are never touched. - *Storage Usage*: disk usage per top-level library folder, largest first, with a simple proportional bar per entry - manually triggered - (it reads every file's size) rather than run automatically. + (it reads every file's size) rather than run automatically. Click any + folder row to drill into its own contents one level at a time, with a + Back button to climb back out. **Dashboard** - Library-wide stats (courses, lessons completed, time watched, time remaining, day streak) and a 90-day activity heatmap +- Favorites — star any course from its list row or its own page for quick + access from the dashboard, independent of recency or curated queues - Next Up queue (manually curated, reorderable) - Pick Back Up (courses with progress that have gone stale) - Recently Added / Recently Viewed @@ -207,6 +214,10 @@ silently stay blank instead of erroring. **Playback & progress** - Video/audio player with resize, playback-speed presets, and resume-from- last-position +- 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 + full list - Auto-tracks watch progress and completion per lesson - Video/audio durations read via `ffprobe` and cached persistently per course (`.offlineu_duration_cache.json`), so total runtime and per-lesson @@ -236,9 +247,9 @@ silently stay blank instead of erroring. **Backup & integrations** - One-click backup export and restore: settings, hidden-path choices, the - Next Up queue, recent-view history, ignored-duplicate pairs, Outline - config, and every course's progress/notes as a zip (not the course files - themselves). Restore + Next Up queue, Favorites, recent-view history, ignored-duplicate pairs, + Outline config, and every course's progress/notes as a zip (not the + course files themselves). Restore overwrites current data and needs a matching course folder to already exist for each course's progress to land - it's a "put my data back" action, not a merge @@ -256,6 +267,7 @@ Everything under `OFFLINEU_DATA_DIR` (app-wide, not tied to a course): | `settings.json` | Theme, layout, library path, etc. | | `hidden_paths.json` | Courses/folders curated out of the browser | | `next_up.json` | The Next Up queue, in order | +| `favorites.json` | Favorited course paths | | `recent_views.json` | Cross-course "Recently Viewed" history | | `ignored_duplicates.json` | Course-path pairs confirmed not duplicates | | `outline_config.json` | Outline API token/collection mapping | diff --git a/VERSION b/VERSION index 237fc0d..2384778 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026-08-24 23:00 UTC — fix video resize handle drifting off-screen +2026-08-24 23:22 UTC — add Favorites, storage drill-down, shortcuts, and What's New diff --git a/offlineu_core.py b/offlineu_core.py index 959fb6c..1ab105e 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -57,6 +57,25 @@ def _load_build_version() -> str: BUILD_VERSION = _load_build_version() + +def load_changelog_entries(limit: int = 10) -> List[str]: + """ + Reads CHANGELOG.md for the Settings page's "What's New" list - one + line per commit (same timestamp + description format as VERSION), + newest first. VERSION itself is overwritten each commit and only ever + shows the current build; this accumulates that same line over time so + you can see what changed recently without digging through git log. + Falls back to an empty list if the file is missing (shouldn't happen + once committed, same reasoning as _load_build_version). + """ + changelog_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'CHANGELOG.md') + try: + with open(changelog_file, 'r') as f: + lines = [line.strip() for line in f if line.strip()] + return lines[:limit] + except OSError: + return [] + # Supported file types VIDEO_EXTENSIONS = {'.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v', '.flv', '.wmv'} AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'} @@ -1052,6 +1071,51 @@ def get_next_up_courses() -> List[Dict[str, Any]]: return result +FAVORITES_FILE = os.path.join(DATA_DIR, 'favorites.json') + + +def get_favorite_paths() -> List[str]: + """Load the set of favorited course paths - order doesn't matter here (favorites always render alphabetically), unlike Next Up's queue.""" + try: + if os.path.exists(FAVORITES_FILE): + with open(FAVORITES_FILE, 'r') as f: + data = json.load(f) + if isinstance(data, list): + return data + except (json.JSONDecodeError, OSError) as e: + print(f"Could not load favorites: {e}") + return [] + + +def _save_favorite_paths(paths: List[str]) -> None: + os.makedirs(DATA_DIR, exist_ok=True) + with open(FAVORITES_FILE, 'w') as f: + json.dump(paths, f, indent=2) + + +def set_path_favorited(path: str, favorited: bool) -> List[str]: + """Add or remove a course from favorites; returns the updated list.""" + paths = get_favorite_paths() + normalized = os.path.abspath(path) + if favorited: + if normalized not in paths: + paths.append(normalized) + else: + paths = [p for p in paths if p != normalized] + _save_favorite_paths(paths) + return paths + + +def get_favorite_courses() -> List[Dict[str, Any]]: + """Favorited courses resolved to displayable summaries (name order, since there's no queue order to preserve), silently dropping any path no longer on disk.""" + results = [] + for path in sorted(get_favorite_paths(), key=lambda p: os.path.basename(p.rstrip(os.sep)).lower()): + p = Path(path) + if p.is_dir(): + results.append(_course_summary(p)) + return results + + def _rebase_prefix(value: str, old_abs: str, new_abs: str) -> str: """If `value` equals or is nested under `old_abs`, rewrite that prefix to `new_abs`.""" if value == old_abs: @@ -1083,6 +1147,11 @@ def rebase_library_path(old_abs: str, new_abs: str) -> None: if rebased_queue != queued: _save_next_up_paths(rebased_queue) + favorited = get_favorite_paths() + rebased_favorites = [_rebase_prefix(p, old_abs, new_abs) for p in favorited] + if rebased_favorites != favorited: + _save_favorite_paths(rebased_favorites) + views = get_recent_views() changed = False for entry in views: @@ -1145,6 +1214,7 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str, directory = Path(dir_path) items: List[Dict[str, Any]] = [] hidden_set = set(get_hidden_paths()) + favorite_set = set(get_favorite_paths()) if not directory.exists() or not directory.is_dir(): return {'items': items, 'errors': [f"Directory not found: {dir_path}"]} @@ -1166,6 +1236,7 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str, if _looks_like_course(entry): item = _course_summary(entry) item['hidden'] = is_hidden + item['favorited'] = os.path.abspath(entry_path_str) in favorite_set item['completion_percentage'] = _course_completion_percentage(entry, item['media_files']) items.append(item) else: @@ -1753,7 +1824,7 @@ def _find_stale_references() -> Dict[str, List[Dict[str, str]]]: /api/library/stale-references/clean is called with the exact items reviewed here. """ - stale: Dict[str, List[Dict[str, str]]] = {'hidden': [], 'next_up': [], 'recent_views': []} + stale: Dict[str, List[Dict[str, str]]] = {'hidden': [], 'next_up': [], 'recent_views': [], 'favorites': []} for path in get_hidden_paths(): if not os.path.isdir(path): @@ -1763,6 +1834,10 @@ def _find_stale_references() -> Dict[str, List[Dict[str, str]]]: if not os.path.isdir(path): stale['next_up'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path}) + for path in get_favorite_paths(): + if not os.path.isdir(path): + stale['favorites'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path}) + seen: Set[str] = set() for entry in get_recent_views(): path = entry.get('course_path', '') @@ -1945,11 +2020,13 @@ def search_library_courses(query: str) -> List[Dict[str, Any]]: get_all_course_dirs. """ query_lower = query.lower() + favorite_set = set(get_favorite_paths()) results = [] for course in get_all_course_dirs(): if query_lower not in course.name.lower(): continue item = _course_summary(course) + item['favorited'] = os.path.abspath(str(course)) in favorite_set item['completion_percentage'] = _course_completion_percentage(course, item['media_files']) results.append(item) return results @@ -2897,6 +2974,7 @@ def index(): stale_courses=format_stale_courses(scan), activity_heatmap=format_activity_heatmap(scan), next_up=get_next_up_courses(), + favorites=get_favorite_courses(), active_tab='home') # Apply progress data to tree @@ -2917,6 +2995,7 @@ def index(): recent_views=recent_views, has_note=bool(course_has_any_notes(current_course)), is_queued=os.path.abspath(current_course.path) in get_next_up_paths(), + is_favorited=os.path.abspath(current_course.path) in get_favorite_paths(), resume_lesson=resume_lesson, active_tab='home') @@ -3280,34 +3359,80 @@ def _format_bytes(n: int) -> str: return f'{size:.1f} TB' +def _list_entries_with_sizes(directory: Path, skip_names: Optional[set] = None) -> List[Dict[str, Any]]: + """ + Every immediate child of `directory` with its total size - a + subdirectory gets its full recursive size (via _directory_size_bytes), + a loose file gets its own size directly. Sorted largest-first. Shared + by the top-level Storage Usage scan and its drill-down into a single + folder, which is otherwise the same operation one level deeper. + """ + skip_names = skip_names or set() + try: + children = [ + p for p in directory.iterdir() + if not p.name.startswith('.') and p.name not in skip_names + ] + except (PermissionError, OSError): + return [] + + entries = [] + for entry in children: + try: + is_dir = entry.is_dir() + size = _directory_size_bytes(entry) if is_dir else entry.stat().st_size + except OSError: + continue + entries.append({ + 'name': entry.name, + 'path': str(entry), + 'bytes': size, + 'human': _format_bytes(size), + 'is_dir': is_dir, + }) + + entries.sort(key=lambda e: e['bytes'], reverse=True) + return entries + + @app.route('/api/library/storage-usage') def storage_usage_api(): """ - Disk usage per top-level library folder (category or a bare course - sitting directly at the root), sorted largest-first, for spotting - what's eating the most space on the NAS. Manually triggered like - Duplicate Courses - a full recursive size scan touches every file in - the library, too expensive to run automatically on page load. + Disk usage of a library folder's immediate contents, sorted + largest-first, for spotting what's eating the most space on the NAS. + With no `path` param, scans the library root's top-level folders + (category or a bare course sitting directly at the root); with `path`, + drills into that folder instead (must resolve inside the library + root). Manually triggered like Duplicate Courses - a full recursive + size scan touches every file under each entry, too expensive to run + automatically on page load. """ - library_root = Path(get_library_root()) - try: - top_level = sorted( - (p for p in library_root.iterdir() if p.is_dir() and not p.name.startswith('.')), - key=lambda p: p.name.lower() - ) - except (PermissionError, OSError) as e: - return jsonify({'error': str(e)}), 500 + library_root = Path(get_library_root()).resolve() + subpath = (request.args.get('path') or '').strip() - entries = [] - for entry in top_level: - if entry.name == UNSORTED_FOLDER_NAME: - continue - size = _directory_size_bytes(entry) - entries.append({'name': entry.name, 'path': str(entry), 'bytes': size, 'human': _format_bytes(size)}) + if subpath: + base = Path(subpath).resolve() + try: + base.relative_to(library_root) + except ValueError: + return jsonify({'error': 'Path is outside the library'}), 400 + if not base.is_dir(): + return jsonify({'error': 'Not a directory'}), 400 + else: + base = library_root - entries.sort(key=lambda e: e['bytes'], reverse=True) + is_root = base == library_root + skip = {UNSORTED_FOLDER_NAME} if is_root else set() + entries = _list_entries_with_sizes(base, skip) total_bytes = sum(e['bytes'] for e in entries) - return jsonify({'entries': entries, 'total_bytes': total_bytes, 'total_human': _format_bytes(total_bytes)}) + return jsonify({ + 'entries': entries, + 'total_bytes': total_bytes, + 'total_human': _format_bytes(total_bytes), + 'path': str(base), + 'is_root': is_root, + 'parent_path': str(base.parent) if not is_root else None, + }) @app.route('/api/library/categories') @@ -3658,6 +3783,9 @@ def clean_stale_references_api(): elif category == 'recent_views': remove_recent_views_for_path(path) removed += 1 + elif category == 'favorites': + set_path_favorited(path, False) + removed += 1 return jsonify({'success': True, 'removed': removed}) @@ -4015,6 +4143,7 @@ def settings_page(): corner_radius_choices=['sharp', 'rounded', 'pill'], active_tab='settings', build_version=BUILD_VERSION, + changelog_entries=load_changelog_entries(), ) @@ -4062,6 +4191,7 @@ BACKUP_ROOT_FILES = { 'settings.json': SETTINGS_FILE, 'hidden_paths.json': HIDDEN_PATHS_FILE, 'next_up.json': NEXT_UP_FILE, + 'favorites.json': FAVORITES_FILE, 'recent_views.json': RECENT_VIEWS_FILE, 'outline_config.json': OUTLINE_CONFIG_FILE, 'ignored_duplicates.json': IGNORED_DUPLICATES_FILE, @@ -4265,6 +4395,24 @@ def set_next_up_api(): return jsonify({'success': True, 'next_up': updated}) +@app.route('/api/favorites', methods=['POST']) +def set_favorite_api(): + """Add or remove a course from favorites.""" + data = request.json or {} + path = data.get('path', '') + favorited = bool(data.get('favorited', True)) + if not path: + return jsonify({'error': 'path is required'}), 400 + + library_root = os.path.abspath(get_library_root()) + target = os.path.abspath(path) + if not (target == library_root or target.startswith(library_root + os.sep)): + return jsonify({'error': 'Path outside library root'}), 403 + + updated = set_path_favorited(target, favorited) + return jsonify({'success': True, 'favorites': updated}) + + @app.route('/api/next-up/bulk', methods=['POST']) def bulk_set_next_up_api(): """Add several courses to the Next Up queue at once.""" diff --git a/templates/_icons.html b/templates/_icons.html index 06c5314..65a70a3 100644 --- a/templates/_icons.html +++ b/templates/_icons.html @@ -5,4 +5,4 @@ platforms and clash with the app's vector logo. Import with {% import '_icons.html' as icons %} and call icons.icon('name', size). #} -{% macro icon(name, size=18) %}{% if name == 'home' %}{% elif name == 'pencil' %}{% elif name == 'help' %}{% elif name == 'settings' %}{% elif name == 'dice' %}{% elif name == 'folder' %}{% elif name == 'graduation-cap' %}{% elif name == 'check' %}{% elif name == 'circle' %}{% elif name == 'x' %}{% elif name == 'pin' %}{% elif name == 'ban' %}{% elif name == 'video' %}{% elif name == 'music' %}{% elif name == 'clipboard' %}{% elif name == 'package' %}{% elif name == 'file-text' %}{% elif name == 'trash' %}{% elif name == 'refresh' %}{% elif name == 'checkbox' %}{% elif name == 'list' %}{% elif name == 'grid' %}{% elif name == 'bar-chart' %}{% elif name == 'list-dots' %}{% elif name == 'history' %}{% elif name == 'plus-circle' %}{% elif name == 'play-circle' %}{% elif name == 'play' %}{% endif %}{% endmacro %} +{% macro icon(name, size=18) %}{% if name == 'home' %}{% elif name == 'pencil' %}{% elif name == 'help' %}{% elif name == 'settings' %}{% elif name == 'dice' %}{% elif name == 'folder' %}{% elif name == 'graduation-cap' %}{% elif name == 'check' %}{% elif name == 'circle' %}{% elif name == 'x' %}{% elif name == 'pin' %}{% elif name == 'ban' %}{% elif name == 'video' %}{% elif name == 'music' %}{% elif name == 'clipboard' %}{% elif name == 'package' %}{% elif name == 'file-text' %}{% elif name == 'trash' %}{% elif name == 'refresh' %}{% elif name == 'checkbox' %}{% elif name == 'list' %}{% elif name == 'grid' %}{% elif name == 'bar-chart' %}{% elif name == 'list-dots' %}{% elif name == 'history' %}{% elif name == 'plus-circle' %}{% elif name == 'play-circle' %}{% elif name == 'play' %}{% elif name == 'star' %}{% endif %}{% endmacro %} diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 07cb122..01f8f7c 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -728,6 +728,13 @@ color: white; border-color: var(--accent); } + .favorite-toggle-btn.active { + color: #e0a825; + border-color: #e0a825; + } + .favorite-toggle-btn.active svg { + fill: currentColor; + } #library-select-btn { border-radius: var(--radius); margin-left: 4px; @@ -1044,6 +1051,9 @@ + {% if has_note %} {{ icons.icon('file-text', 14) }} Download Study Guide {% endif %} @@ -1249,6 +1259,31 @@ {% endif %} + {% if favorites %} +
+
+

{{ icons.icon('star') }}Favorites

+
+ {% for item in favorites %} +
+
+ {% if item.has_thumbnail %} + + {% else %} + {{ icons.icon('graduation-cap', 16) }} + {% endif %} + {{ item.name }} +
+
+ +
+
+ {% endfor %} +
+
+
+ {% endif %} {% if next_up %}
@@ -1346,6 +1381,8 @@ play: '', 'graduation-cap': '', pin: '', + star: '', + 'star-filled': '', folder: '', }; @@ -1708,6 +1745,7 @@ const progressBar = pct ? `
` : ''; + const favorited = !!item.favorited; return `
@@ -1718,6 +1756,7 @@
${progressBadge} ${item.media_files} media file${item.media_files === 1 ? '' : 's'}${item.duration_display ? ` · ${item.duration_display}` : ''} +
${progressBar} @@ -1889,6 +1928,50 @@ saveNextUpOrder(); } + function removeFavorite(btnEl) { + const row = btnEl.closest('.lesson-item'); + const path = row.dataset.path; + fetch('/api/favorites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: path, favorited: false }) + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + row.remove(); + const list = document.getElementById('favorites-list'); + if (list && list.children.length === 0) { + document.getElementById('favorites-card').closest('.container').remove(); + } + document.querySelectorAll(`.favorite-toggle-btn[data-path="${CSS.escape(path)}"]`).forEach(btn => { + btn.classList.remove('active'); + btn.innerHTML = ICON_SVGS.star; + btn.title = 'Add to Favorites'; + }); + } + }) + .catch(() => {}); + } + + function favoriteCourse(path, btnEl) { + const nowFavorited = !btnEl.classList.contains('active'); + fetch('/api/favorites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: path, favorited: nowFavorited }) + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + btnEl.classList.toggle('active', nowFavorited); + btnEl.innerHTML = nowFavorited ? ICON_SVGS['star-filled'] : ICON_SVGS.star; + btnEl.title = nowFavorited ? 'Remove from Favorites' : 'Add to Favorites'; + } + }) + .catch(() => {}); + } + function removeNextUp(btnEl) { const row = btnEl.closest('.lesson-item'); const path = row.dataset.path; @@ -1940,6 +2023,24 @@ }) .catch(() => {}); } + + function toggleFavorited() { + const btn = document.getElementById('favorite-toggle-btn'); + const currentlyFavorited = btn.classList.contains('active'); + fetch('/api/favorites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: {{ course.path|tojson }}, favorited: !currentlyFavorited }) + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + btn.classList.toggle('active', !currentlyFavorited); + btn.innerHTML = ICON_SVGS.star + ' ' + (currentlyFavorited ? 'Add to Favorites' : 'Remove from Favorites'); + } + }) + .catch(() => {}); + } {% endif %} // ---- Collapsible dashboard cards (client-only, remembered per device) ---- diff --git a/templates/help.html b/templates/help.html index 16d9efb..c44e52a 100644 --- a/templates/help.html +++ b/templates/help.html @@ -173,6 +173,9 @@
  • Space - play/pause
  • ← / → - seek back/forward 10 seconds
  • ↑ / ↓ - volume up/down
  • +
  • , / . - step playback speed down/up
  • +
  • [ / ] - previous/next lesson
  • +
  • F - toggle fullscreen
  • N - jump into the quick-capture note box (pauses the video)
  • Drag the video's bottom-right corner to resize it, and pick a default playback speed in Settings - both are remembered
  • Reopening a lesson resumes from where you left off, and the ▶ Resume button at the top of a loaded course jumps straight back to the last lesson you had open
  • diff --git a/templates/lesson_view.html b/templates/lesson_view.html index 80fc9c9..5ba6e4c 100644 --- a/templates/lesson_view.html +++ b/templates/lesson_view.html @@ -1291,6 +1291,18 @@ return tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' || el.isContentEditable; } + const PLAYBACK_SPEEDS = ['0.75', '1', '1.25', '1.5', '1.75', '2']; + + function stepPlaybackSpeed(direction) { + if (!activeMedia) return; + let idx = PLAYBACK_SPEEDS.findIndex(s => parseFloat(s) === activeMedia.playbackRate); + if (idx === -1) idx = PLAYBACK_SPEEDS.indexOf('1'); + idx = Math.max(0, Math.min(PLAYBACK_SPEEDS.length - 1, idx + direction)); + const speed = PLAYBACK_SPEEDS[idx]; + setPlaybackSpeed(speed); + showNotification(`Speed: ${speed}x`); + } + document.addEventListener('keydown', function(e) { if (isTypingTarget(e.target)) return; if ((e.key === 'n' || e.key === 'N') && !e.ctrlKey && !e.altKey && !e.metaKey) { @@ -1298,6 +1310,33 @@ openQuickCapture(); return; } + if (!e.ctrlKey && !e.altKey && !e.metaKey) { + switch(e.key) { + case '[': + {% if prev_lesson %} + e.preventDefault(); + window.location.href = '/lesson/{{ prev_lesson }}'; + {% endif %} + break; + case ']': + {% if next_lesson %} + e.preventDefault(); + window.location.href = '/lesson/{{ next_lesson }}'; + {% endif %} + break; + case 'f': + case 'F': + if (video) { + e.preventDefault(); + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + video.requestFullscreen(); + } + } + break; + } + } if (activeMedia && !e.ctrlKey && !e.altKey && !e.metaKey) { switch(e.key) { case ' ': @@ -1326,6 +1365,14 @@ activeMedia.volume = Math.max(0, activeMedia.volume - 0.1); showNotification(`Volume: ${Math.round(activeMedia.volume * 100)}%`); break; + case ',': + e.preventDefault(); + stepPlaybackSpeed(-1); + break; + case '.': + e.preventDefault(); + stepPlaybackSpeed(1); + break; } } }); diff --git a/templates/settings.html b/templates/settings.html index ce6f091..872ca6d 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -470,6 +470,17 @@

    Version {{ build_version }}

    + + {% if changelog_entries %} +
    + What's New +
      + {% for entry in changelog_entries %} +
    • {{ entry }}
    • + {% endfor %} +
    +
    + {% endif %}
    diff --git a/templates/unsorted.html b/templates/unsorted.html index c8cf45d..576179b 100644 --- a/templates/unsorted.html +++ b/templates/unsorted.html @@ -325,6 +325,12 @@ .storage-row:first-of-type { border-top: none; } + .storage-row-clickable { + cursor: pointer; + } + .storage-row-clickable:hover { + background: var(--bg-tertiary); + } .storage-row-name { flex: 0 0 160px; font-weight: 600; @@ -662,6 +668,7 @@
    +
    @@ -1675,15 +1682,20 @@ } // ---- Storage Usage ---- - function scanStorageUsage() { + let storagePathStack = []; + + function scanStorageUsage(path) { const btn = document.getElementById('storage-scan-btn'); const status = document.getElementById('storage-status'); const results = document.getElementById('storage-results'); + const breadcrumb = document.getElementById('storage-breadcrumb'); + if (!path) storagePathStack = []; btn.disabled = true; status.style.color = 'var(--text-muted)'; status.textContent = 'Scanning… this reads every file, may take a moment.'; results.innerHTML = ''; - fetch('/api/library/storage-usage') + const url = path ? `/api/library/storage-usage?path=${encodeURIComponent(path)}` : '/api/library/storage-usage'; + fetch(url) .then(r => r.json()) .then(data => { btn.disabled = false; @@ -1692,6 +1704,9 @@ status.textContent = data.error; return; } + breadcrumb.innerHTML = !data.is_root + ? ` ${escapeHtml(data.path)}` + : ''; const entries = data.entries || []; if (!entries.length) { status.style.color = 'var(--text-muted)'; @@ -1702,7 +1717,7 @@ status.textContent = `Total: ${data.total_human}`; const maxBytes = Math.max(...entries.map(e => e.bytes), 1); results.innerHTML = entries.map(e => ` -
    +
    ${escapeHtml(e.name)}
    ${escapeHtml(e.human)} @@ -1716,6 +1731,21 @@ }); } + function storageDrillDown(path) { + const status = document.getElementById('storage-status'); + const current = status.dataset.currentPath || ''; + storagePathStack.push(current); + status.dataset.currentPath = path; + scanStorageUsage(path); + } + + function storageGoBack() { + const status = document.getElementById('storage-status'); + const prev = storagePathStack.pop() || ''; + status.dataset.currentPath = prev; + scanStorageUsage(prev || undefined); + } + // ---- Duplicate Courses ---- function scanDuplicates() { const btn = document.getElementById('dup-scan-btn'); @@ -1918,9 +1948,9 @@ .then(r => r.json()) .then(data => { btn.disabled = false; - const categoryLabels = { hidden: 'Hidden', next_up: 'Next Up', recent_views: 'Recently Viewed' }; + const categoryLabels = { hidden: 'Hidden', next_up: 'Next Up', recent_views: 'Recently Viewed', favorites: 'Favorite' }; const items = []; - for (const category of ['hidden', 'next_up', 'recent_views']) { + for (const category of ['hidden', 'next_up', 'recent_views', 'favorites']) { for (const entry of (data[category] || [])) { items.push({ path: entry.path, name: entry.name, category, label: categoryLabels[category] }); }