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 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 19:23:40 -04:00
co-authored by Claude Sonnet 5
parent 1fbf0c9312
commit fc83876abe
10 changed files with 404 additions and 39 deletions
+13
View File
@@ -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
+22 -10
View File
@@ -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, time - Dockhand's build context doesn't reliably have `.git` available,
which silently produced "unknown" instead of an actual commit. Reads as which silently produced "unknown" instead of an actual commit. Reads as
`dev` outside Docker (no `VERSION` file to read, e.g. before the first `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 listed (and reversible) under "Ignored matches," and persist through
backup/restore alongside hidden paths and Next Up. backup/restore alongside hidden paths and Next Up.
- *Clean Up Stale References*: finds entries in the hidden-paths list, - *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 Next Up queue, Favorites, or Recently Viewed history that point at a path
on disk - normally from renaming/moving/deleting a course directly on no longer on disk - normally from renaming/moving/deleting a course
the NAS instead of through the app (doing it through the app already directly on the NAS instead of through the app (doing it through the app
keeps these in sync). Review individually or clear them all at once; already keeps these in sync). Review individually or clear them all at
course files themselves are never touched. once; course files themselves are never touched.
- *Storage Usage*: disk usage per top-level library folder, largest - *Storage Usage*: disk usage per top-level library folder, largest
first, with a simple proportional bar per entry - manually triggered 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** **Dashboard**
- Library-wide stats (courses, lessons completed, time watched, time - Library-wide stats (courses, lessons completed, time watched, time
remaining, day streak) and a 90-day activity heatmap 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) - Next Up queue (manually curated, reorderable)
- Pick Back Up (courses with progress that have gone stale) - Pick Back Up (courses with progress that have gone stale)
- Recently Added / Recently Viewed - Recently Added / Recently Viewed
@@ -207,6 +214,10 @@ silently stay blank instead of erroring.
**Playback & progress** **Playback & progress**
- Video/audio player with resize, playback-speed presets, and resume-from- - Video/audio player with resize, playback-speed presets, and resume-from-
last-position 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 - Auto-tracks watch progress and completion per lesson
- Video/audio durations read via `ffprobe` and cached persistently per - Video/audio durations read via `ffprobe` and cached persistently per
course (`.offlineu_duration_cache.json`), so total runtime and per-lesson course (`.offlineu_duration_cache.json`), so total runtime and per-lesson
@@ -236,9 +247,9 @@ silently stay blank instead of erroring.
**Backup & integrations** **Backup & integrations**
- One-click backup export and restore: settings, hidden-path choices, the - One-click backup export and restore: settings, hidden-path choices, the
Next Up queue, recent-view history, ignored-duplicate pairs, Outline Next Up queue, Favorites, recent-view history, ignored-duplicate pairs,
config, and every course's progress/notes as a zip (not the course files Outline config, and every course's progress/notes as a zip (not the
themselves). Restore course files themselves). Restore
overwrites current data and needs a matching course folder to already 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" exist for each course's progress to land - it's a "put my data back"
action, not a merge 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. | | `settings.json` | Theme, layout, library path, etc. |
| `hidden_paths.json` | Courses/folders curated out of the browser | | `hidden_paths.json` | Courses/folders curated out of the browser |
| `next_up.json` | The Next Up queue, in order | | `next_up.json` | The Next Up queue, in order |
| `favorites.json` | Favorited course paths |
| `recent_views.json` | Cross-course "Recently Viewed" history | | `recent_views.json` | Cross-course "Recently Viewed" history |
| `ignored_duplicates.json` | Course-path pairs confirmed not duplicates | | `ignored_duplicates.json` | Course-path pairs confirmed not duplicates |
| `outline_config.json` | Outline API token/collection mapping | | `outline_config.json` | Outline API token/collection mapping |
+1 -1
View File
@@ -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
+170 -22
View File
@@ -57,6 +57,25 @@ def _load_build_version() -> str:
BUILD_VERSION = _load_build_version() 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 # Supported file types
VIDEO_EXTENSIONS = {'.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v', '.flv', '.wmv'} VIDEO_EXTENSIONS = {'.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v', '.flv', '.wmv'}
AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'} AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'}
@@ -1052,6 +1071,51 @@ def get_next_up_courses() -> List[Dict[str, Any]]:
return result 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: 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` equals or is nested under `old_abs`, rewrite that prefix to `new_abs`."""
if value == old_abs: if value == old_abs:
@@ -1083,6 +1147,11 @@ def rebase_library_path(old_abs: str, new_abs: str) -> None:
if rebased_queue != queued: if rebased_queue != queued:
_save_next_up_paths(rebased_queue) _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() views = get_recent_views()
changed = False changed = False
for entry in views: 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) directory = Path(dir_path)
items: List[Dict[str, Any]] = [] items: List[Dict[str, Any]] = []
hidden_set = set(get_hidden_paths()) hidden_set = set(get_hidden_paths())
favorite_set = set(get_favorite_paths())
if not directory.exists() or not directory.is_dir(): if not directory.exists() or not directory.is_dir():
return {'items': items, 'errors': [f"Directory not found: {dir_path}"]} 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): if _looks_like_course(entry):
item = _course_summary(entry) item = _course_summary(entry)
item['hidden'] = is_hidden 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']) item['completion_percentage'] = _course_completion_percentage(entry, item['media_files'])
items.append(item) items.append(item)
else: 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 /api/library/stale-references/clean is called with the exact items
reviewed here. 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(): for path in get_hidden_paths():
if not os.path.isdir(path): 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): if not os.path.isdir(path):
stale['next_up'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or 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() seen: Set[str] = set()
for entry in get_recent_views(): for entry in get_recent_views():
path = entry.get('course_path', '') path = entry.get('course_path', '')
@@ -1945,11 +2020,13 @@ def search_library_courses(query: str) -> List[Dict[str, Any]]:
get_all_course_dirs. get_all_course_dirs.
""" """
query_lower = query.lower() query_lower = query.lower()
favorite_set = set(get_favorite_paths())
results = [] results = []
for course in get_all_course_dirs(): for course in get_all_course_dirs():
if query_lower not in course.name.lower(): if query_lower not in course.name.lower():
continue continue
item = _course_summary(course) item = _course_summary(course)
item['favorited'] = os.path.abspath(str(course)) in favorite_set
item['completion_percentage'] = _course_completion_percentage(course, item['media_files']) item['completion_percentage'] = _course_completion_percentage(course, item['media_files'])
results.append(item) results.append(item)
return results return results
@@ -2897,6 +2974,7 @@ def index():
stale_courses=format_stale_courses(scan), stale_courses=format_stale_courses(scan),
activity_heatmap=format_activity_heatmap(scan), activity_heatmap=format_activity_heatmap(scan),
next_up=get_next_up_courses(), next_up=get_next_up_courses(),
favorites=get_favorite_courses(),
active_tab='home') active_tab='home')
# Apply progress data to tree # Apply progress data to tree
@@ -2917,6 +2995,7 @@ def index():
recent_views=recent_views, recent_views=recent_views,
has_note=bool(course_has_any_notes(current_course)), has_note=bool(course_has_any_notes(current_course)),
is_queued=os.path.abspath(current_course.path) in get_next_up_paths(), 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, resume_lesson=resume_lesson,
active_tab='home') active_tab='home')
@@ -3280,34 +3359,80 @@ def _format_bytes(n: int) -> str:
return f'{size:.1f} TB' 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') @app.route('/api/library/storage-usage')
def storage_usage_api(): def storage_usage_api():
""" """
Disk usage per top-level library folder (category or a bare course Disk usage of a library folder's immediate contents, sorted
sitting directly at the root), sorted largest-first, for spotting largest-first, for spotting what's eating the most space on the NAS.
what's eating the most space on the NAS. Manually triggered like With no `path` param, scans the library root's top-level folders
Duplicate Courses - a full recursive size scan touches every file in (category or a bare course sitting directly at the root); with `path`,
the library, too expensive to run automatically on page load. 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()) library_root = Path(get_library_root()).resolve()
subpath = (request.args.get('path') or '').strip()
if subpath:
base = Path(subpath).resolve()
try: try:
top_level = sorted( base.relative_to(library_root)
(p for p in library_root.iterdir() if p.is_dir() and not p.name.startswith('.')), except ValueError:
key=lambda p: p.name.lower() return jsonify({'error': 'Path is outside the library'}), 400
) if not base.is_dir():
except (PermissionError, OSError) as e: return jsonify({'error': 'Not a directory'}), 400
return jsonify({'error': str(e)}), 500 else:
base = library_root
entries = [] is_root = base == library_root
for entry in top_level: skip = {UNSORTED_FOLDER_NAME} if is_root else set()
if entry.name == UNSORTED_FOLDER_NAME: entries = _list_entries_with_sizes(base, skip)
continue
size = _directory_size_bytes(entry)
entries.append({'name': entry.name, 'path': str(entry), 'bytes': size, 'human': _format_bytes(size)})
entries.sort(key=lambda e: e['bytes'], reverse=True)
total_bytes = sum(e['bytes'] for e in entries) 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') @app.route('/api/library/categories')
@@ -3658,6 +3783,9 @@ def clean_stale_references_api():
elif category == 'recent_views': elif category == 'recent_views':
remove_recent_views_for_path(path) remove_recent_views_for_path(path)
removed += 1 removed += 1
elif category == 'favorites':
set_path_favorited(path, False)
removed += 1
return jsonify({'success': True, 'removed': removed}) return jsonify({'success': True, 'removed': removed})
@@ -4015,6 +4143,7 @@ def settings_page():
corner_radius_choices=['sharp', 'rounded', 'pill'], corner_radius_choices=['sharp', 'rounded', 'pill'],
active_tab='settings', active_tab='settings',
build_version=BUILD_VERSION, build_version=BUILD_VERSION,
changelog_entries=load_changelog_entries(),
) )
@@ -4062,6 +4191,7 @@ BACKUP_ROOT_FILES = {
'settings.json': SETTINGS_FILE, 'settings.json': SETTINGS_FILE,
'hidden_paths.json': HIDDEN_PATHS_FILE, 'hidden_paths.json': HIDDEN_PATHS_FILE,
'next_up.json': NEXT_UP_FILE, 'next_up.json': NEXT_UP_FILE,
'favorites.json': FAVORITES_FILE,
'recent_views.json': RECENT_VIEWS_FILE, 'recent_views.json': RECENT_VIEWS_FILE,
'outline_config.json': OUTLINE_CONFIG_FILE, 'outline_config.json': OUTLINE_CONFIG_FILE,
'ignored_duplicates.json': IGNORED_DUPLICATES_FILE, 'ignored_duplicates.json': IGNORED_DUPLICATES_FILE,
@@ -4265,6 +4395,24 @@ def set_next_up_api():
return jsonify({'success': True, 'next_up': updated}) 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']) @app.route('/api/next-up/bulk', methods=['POST'])
def bulk_set_next_up_api(): def bulk_set_next_up_api():
"""Add several courses to the Next Up queue at once.""" """Add several courses to the Next Up queue at once."""
+1 -1
View File
@@ -5,4 +5,4 @@
platforms and clash with the app's vector logo. Import with platforms and clash with the app's vector logo. Import with
{% import '_icons.html' as icons %} and call icons.icon('name', size). {% import '_icons.html' as icons %} and call icons.icon('name', size).
#} #}
{% macro icon(name, size=18) %}<svg class="icon" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{% if name == 'home' %}<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"></path>{% elif name == 'pencil' %}<path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>{% elif name == 'help' %}<circle cx="12" cy="12" r="9"></circle><path d="M9.1 9a2.9 2.9 0 0 1 5.6 1c0 2-2.9 2.5-2.9 4.5"></path><line x1="12" y1="17.5" x2="12" y2="17.51"></line>{% elif name == 'settings' %}<circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"></path>{% elif name == 'dice' %}<rect x="3" y="3" width="18" height="18" rx="4"></rect><circle cx="8" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="8" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"></circle>{% elif name == 'folder' %}<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>{% elif name == 'graduation-cap' %}<path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path>{% elif name == 'check' %}<polyline points="20 6 9 17 4 12"></polyline>{% elif name == 'circle' %}<circle cx="12" cy="12" r="9"></circle>{% elif name == 'x' %}<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>{% elif name == 'pin' %}<path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle>{% elif name == 'ban' %}<circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line>{% elif name == 'video' %}<rect x="2" y="5" width="14" height="14" rx="2"></rect><path d="M16 10.5 22 7v10l-6-3.5Z"></path>{% elif name == 'music' %}<path d="M9 18V5l11-2v13"></path><circle cx="6.5" cy="18" r="2.5"></circle><circle cx="17.5" cy="16" r="2.5"></circle>{% elif name == 'clipboard' %}<rect x="6" y="4" width="12" height="17" rx="2"></rect><path d="M9 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"></path><line x1="9" y1="11" x2="15" y2="11"></line><line x1="9" y1="15" x2="13" y2="15"></line>{% elif name == 'package' %}<path d="M21 8 12 3 3 8v8l9 5 9-5Z"></path><path d="M3 8l9 5 9-5M12 13v8"></path>{% elif name == 'file-text' %}<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"></path><path d="M14 2v6h6"></path><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="15" y2="17"></line>{% elif name == 'trash' %}<path d="M4 7h16"></path><path d="M10 11v6M14 11v6"></path><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"></path><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"></path>{% elif name == 'refresh' %}<path d="M21 12a9 9 0 0 1-15.3 6.4"></path><path d="M3 12a9 9 0 0 1 15.3-6.4"></path><polyline points="21 3 21 9 15 9"></polyline><polyline points="3 21 3 15 9 15"></polyline>{% elif name == 'checkbox' %}<path d="M9 11l3 3 7-7"></path><path d="M21 12v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10"></path>{% elif name == 'list' %}<line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line>{% elif name == 'grid' %}<rect x="3" y="3" width="8" height="8" rx="1.5"></rect><rect x="13" y="3" width="8" height="8" rx="1.5"></rect><rect x="3" y="13" width="8" height="8" rx="1.5"></rect><rect x="13" y="13" width="8" height="8" rx="1.5"></rect>{% elif name == 'bar-chart' %}<line x1="6" y1="20" x2="6" y2="14"></line><line x1="12" y1="20" x2="12" y2="8"></line><line x1="18" y1="20" x2="18" y2="11"></line>{% elif name == 'list-dots' %}<line x1="8" y1="6" x2="20" y2="6"></line><line x1="8" y1="12" x2="20" y2="12"></line><line x1="8" y1="18" x2="20" y2="18"></line><circle cx="4" cy="6" r="1"></circle><circle cx="4" cy="12" r="1"></circle><circle cx="4" cy="18" r="1"></circle>{% elif name == 'history' %}<path d="M3 12a9 9 0 1 0 3-6.7"></path><polyline points="3 4 3 9 8 9"></polyline>{% elif name == 'plus-circle' %}<circle cx="12" cy="12" r="9"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>{% elif name == 'play-circle' %}<circle cx="12" cy="12" r="9"></circle><polygon points="10 8 16 12 10 16 10 8"></polygon>{% elif name == 'play' %}<polygon points="6 3 20 12 6 21 6 3"></polygon>{% endif %}</svg>{% endmacro %} {% macro icon(name, size=18) %}<svg class="icon" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{% if name == 'home' %}<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"></path>{% elif name == 'pencil' %}<path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>{% elif name == 'help' %}<circle cx="12" cy="12" r="9"></circle><path d="M9.1 9a2.9 2.9 0 0 1 5.6 1c0 2-2.9 2.5-2.9 4.5"></path><line x1="12" y1="17.5" x2="12" y2="17.51"></line>{% elif name == 'settings' %}<circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"></path>{% elif name == 'dice' %}<rect x="3" y="3" width="18" height="18" rx="4"></rect><circle cx="8" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="8" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"></circle>{% elif name == 'folder' %}<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>{% elif name == 'graduation-cap' %}<path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path>{% elif name == 'check' %}<polyline points="20 6 9 17 4 12"></polyline>{% elif name == 'circle' %}<circle cx="12" cy="12" r="9"></circle>{% elif name == 'x' %}<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>{% elif name == 'pin' %}<path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle>{% elif name == 'ban' %}<circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line>{% elif name == 'video' %}<rect x="2" y="5" width="14" height="14" rx="2"></rect><path d="M16 10.5 22 7v10l-6-3.5Z"></path>{% elif name == 'music' %}<path d="M9 18V5l11-2v13"></path><circle cx="6.5" cy="18" r="2.5"></circle><circle cx="17.5" cy="16" r="2.5"></circle>{% elif name == 'clipboard' %}<rect x="6" y="4" width="12" height="17" rx="2"></rect><path d="M9 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"></path><line x1="9" y1="11" x2="15" y2="11"></line><line x1="9" y1="15" x2="13" y2="15"></line>{% elif name == 'package' %}<path d="M21 8 12 3 3 8v8l9 5 9-5Z"></path><path d="M3 8l9 5 9-5M12 13v8"></path>{% elif name == 'file-text' %}<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"></path><path d="M14 2v6h6"></path><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="15" y2="17"></line>{% elif name == 'trash' %}<path d="M4 7h16"></path><path d="M10 11v6M14 11v6"></path><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"></path><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"></path>{% elif name == 'refresh' %}<path d="M21 12a9 9 0 0 1-15.3 6.4"></path><path d="M3 12a9 9 0 0 1 15.3-6.4"></path><polyline points="21 3 21 9 15 9"></polyline><polyline points="3 21 3 15 9 15"></polyline>{% elif name == 'checkbox' %}<path d="M9 11l3 3 7-7"></path><path d="M21 12v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10"></path>{% elif name == 'list' %}<line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line>{% elif name == 'grid' %}<rect x="3" y="3" width="8" height="8" rx="1.5"></rect><rect x="13" y="3" width="8" height="8" rx="1.5"></rect><rect x="3" y="13" width="8" height="8" rx="1.5"></rect><rect x="13" y="13" width="8" height="8" rx="1.5"></rect>{% elif name == 'bar-chart' %}<line x1="6" y1="20" x2="6" y2="14"></line><line x1="12" y1="20" x2="12" y2="8"></line><line x1="18" y1="20" x2="18" y2="11"></line>{% elif name == 'list-dots' %}<line x1="8" y1="6" x2="20" y2="6"></line><line x1="8" y1="12" x2="20" y2="12"></line><line x1="8" y1="18" x2="20" y2="18"></line><circle cx="4" cy="6" r="1"></circle><circle cx="4" cy="12" r="1"></circle><circle cx="4" cy="18" r="1"></circle>{% elif name == 'history' %}<path d="M3 12a9 9 0 1 0 3-6.7"></path><polyline points="3 4 3 9 8 9"></polyline>{% elif name == 'plus-circle' %}<circle cx="12" cy="12" r="9"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>{% elif name == 'play-circle' %}<circle cx="12" cy="12" r="9"></circle><polygon points="10 8 16 12 10 16 10 8"></polygon>{% elif name == 'play' %}<polygon points="6 3 20 12 6 21 6 3"></polygon>{% elif name == 'star' %}<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>{% endif %}</svg>{% endmacro %}
+101
View File
@@ -728,6 +728,13 @@
color: white; color: white;
border-color: var(--accent); border-color: var(--accent);
} }
.favorite-toggle-btn.active {
color: #e0a825;
border-color: #e0a825;
}
.favorite-toggle-btn.active svg {
fill: currentColor;
}
#library-select-btn { #library-select-btn {
border-radius: var(--radius); border-radius: var(--radius);
margin-left: 4px; margin-left: 4px;
@@ -1044,6 +1051,9 @@
<button class="btn btn-secondary btn-sm" id="queue-toggle-btn" onclick="toggleQueued()"> <button class="btn btn-secondary btn-sm" id="queue-toggle-btn" onclick="toggleQueued()">
{{ icons.icon('pin', 14) }} {{ 'Remove from Next Up' if is_queued else 'Add to Next Up' }} {{ icons.icon('pin', 14) }} {{ 'Remove from Next Up' if is_queued else 'Add to Next Up' }}
</button> </button>
<button class="btn btn-secondary btn-sm{{ ' favorite-toggle-btn active' if is_favorited else ' favorite-toggle-btn' }}" id="favorite-toggle-btn" onclick="toggleFavorited()">
{{ icons.icon('star', 14) }} {{ 'Remove from Favorites' if is_favorited else 'Add to Favorites' }}
</button>
{% if has_note %} {% if has_note %}
<a class="btn btn-secondary btn-sm" href="/course/study-guide">{{ icons.icon('file-text', 14) }} Download Study Guide</a> <a class="btn btn-secondary btn-sm" href="/course/study-guide">{{ icons.icon('file-text', 14) }} Download Study Guide</a>
{% endif %} {% endif %}
@@ -1249,6 +1259,31 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if favorites %}
<div class="container">
<div class="card" id="favorites-card">
<h2 class="collapsible-header" data-card-id="favorites"><span class="card-header-label">{{ icons.icon('star') }}Favorites</span></h2>
<div class="card-body" id="favorites-list">
{% for item in favorites %}
<div class="lesson-item" data-path="{{ item.path }}">
<div class="lesson-title" style="cursor: pointer;" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
{% if item.has_thumbnail %}
<img class="lesson-thumb" src="/library/thumbnail?path={{ item.path | urlencode }}" alt=""
onerror="this.replaceWith(courseFallbackIcon('graduation-cap'))">
{% else %}
<span class="lesson-icon">{{ icons.icon('graduation-cap', 16) }}</span>
{% endif %}
<span class="lesson-name">{{ item.name }}</span>
</div>
<div class="lesson-meta">
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); removeFavorite(this)" title="Remove from Favorites">{{ icons.icon('x', 14) }}</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if next_up %} {% if next_up %}
<div class="container"> <div class="container">
<div class="card" id="next-up-card"> <div class="card" id="next-up-card">
@@ -1346,6 +1381,8 @@
play: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>', play: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>',
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>', 'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
pin: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle></svg>', pin: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle></svg>',
star: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>',
'star-filled': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>',
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>', folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
}; };
@@ -1708,6 +1745,7 @@
const progressBar = pct const progressBar = pct
? `<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: ${pct}%;"></div></div>` ? `<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: ${pct}%;"></div></div>`
: ''; : '';
const favorited = !!item.favorited;
return ` return `
<div class="lesson-item" onclick="${clickHandler}"> <div class="lesson-item" onclick="${clickHandler}">
<div class="lesson-title"> <div class="lesson-title">
@@ -1718,6 +1756,7 @@
<div class="lesson-meta"> <div class="lesson-meta">
${progressBadge} ${progressBadge}
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}${item.duration_display ? ` · ${item.duration_display}` : ''}</span> <span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}${item.duration_display ? ` · ${item.duration_display}` : ''}</span>
<button class="btn btn-secondary btn-sm favorite-toggle-btn${favorited ? ' active' : ''}" data-path="${escapeAttr(item.path)}" onclick="event.stopPropagation(); favoriteCourse('${safePath}', this)" title="${favorited ? 'Remove from Favorites' : 'Add to Favorites'}">${favorited ? ICON_SVGS['star-filled'] : ICON_SVGS.star}</button>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">${ICON_SVGS.pin}</button> <button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">${ICON_SVGS.pin}</button>
</div> </div>
${progressBar} ${progressBar}
@@ -1889,6 +1928,50 @@
saveNextUpOrder(); 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) { function removeNextUp(btnEl) {
const row = btnEl.closest('.lesson-item'); const row = btnEl.closest('.lesson-item');
const path = row.dataset.path; const path = row.dataset.path;
@@ -1940,6 +2023,24 @@
}) })
.catch(() => {}); .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 %} {% endif %}
// ---- Collapsible dashboard cards (client-only, remembered per device) ---- // ---- Collapsible dashboard cards (client-only, remembered per device) ----
+3
View File
@@ -173,6 +173,9 @@
<li><strong>Space</strong> - play/pause</li> <li><strong>Space</strong> - play/pause</li>
<li><strong>← / →</strong> - seek back/forward 10 seconds</li> <li><strong>← / →</strong> - seek back/forward 10 seconds</li>
<li><strong>↑ / ↓</strong> - volume up/down</li> <li><strong>↑ / ↓</strong> - volume up/down</li>
<li><strong>, / .</strong> - step playback speed down/up</li>
<li><strong>[ / ]</strong> - previous/next lesson</li>
<li><strong>F</strong> - toggle fullscreen</li>
<li><strong>N</strong> - jump into the quick-capture note box (pauses the video)</li> <li><strong>N</strong> - jump into the quick-capture note box (pauses the video)</li>
<li>Drag the video's bottom-right corner to resize it, and pick a default playback speed in Settings - both are remembered</li> <li>Drag the video's bottom-right corner to resize it, and pick a default playback speed in Settings - both are remembered</li>
<li>Reopening a lesson resumes from where you left off, and the <strong>▶ Resume</strong> button at the top of a loaded course jumps straight back to the last lesson you had open</li> <li>Reopening a lesson resumes from where you left off, and the <strong>▶ Resume</strong> button at the top of a loaded course jumps straight back to the last lesson you had open</li>
+47
View File
@@ -1291,6 +1291,18 @@
return tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' || el.isContentEditable; 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) { document.addEventListener('keydown', function(e) {
if (isTypingTarget(e.target)) return; if (isTypingTarget(e.target)) return;
if ((e.key === 'n' || e.key === 'N') && !e.ctrlKey && !e.altKey && !e.metaKey) { if ((e.key === 'n' || e.key === 'N') && !e.ctrlKey && !e.altKey && !e.metaKey) {
@@ -1298,6 +1310,33 @@
openQuickCapture(); openQuickCapture();
return; 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) { if (activeMedia && !e.ctrlKey && !e.altKey && !e.metaKey) {
switch(e.key) { switch(e.key) {
case ' ': case ' ':
@@ -1326,6 +1365,14 @@
activeMedia.volume = Math.max(0, activeMedia.volume - 0.1); activeMedia.volume = Math.max(0, activeMedia.volume - 0.1);
showNotification(`Volume: ${Math.round(activeMedia.volume * 100)}%`); showNotification(`Volume: ${Math.round(activeMedia.volume * 100)}%`);
break; break;
case ',':
e.preventDefault();
stepPlaybackSpeed(-1);
break;
case '.':
e.preventDefault();
stepPlaybackSpeed(1);
break;
} }
} }
}); });
+11
View File
@@ -470,6 +470,17 @@
<p style="text-align: center; color: var(--text-muted); font-size: 0.8em; margin-top: 30px;"> <p style="text-align: center; color: var(--text-muted); font-size: 0.8em; margin-top: 30px;">
Version {{ build_version }} Version {{ build_version }}
</p> </p>
{% if changelog_entries %}
<details style="max-width: 500px; margin: 10px auto 0; color: var(--text-muted); font-size: 0.8em;">
<summary style="text-align: center; cursor: pointer;">What's New</summary>
<ul style="margin: 8px 0 0; padding-left: 20px;">
{% for entry in changelog_entries %}
<li style="margin-bottom: 4px;">{{ entry }}</li>
{% endfor %}
</ul>
</details>
{% endif %}
</div> </div>
</div> </div>
+35 -5
View File
@@ -325,6 +325,12 @@
.storage-row:first-of-type { .storage-row:first-of-type {
border-top: none; border-top: none;
} }
.storage-row-clickable {
cursor: pointer;
}
.storage-row-clickable:hover {
background: var(--bg-tertiary);
}
.storage-row-name { .storage-row-name {
flex: 0 0 160px; flex: 0 0 160px;
font-weight: 600; font-weight: 600;
@@ -662,6 +668,7 @@
<button class="btn btn-secondary" id="storage-scan-btn" onclick="scanStorageUsage()">{{ icons.icon('refresh', 14) }} Scan Disk Usage</button> <button class="btn btn-secondary" id="storage-scan-btn" onclick="scanStorageUsage()">{{ icons.icon('refresh', 14) }} Scan Disk Usage</button>
<span id="storage-status"></span> <span id="storage-status"></span>
</div> </div>
<div id="storage-breadcrumb"></div>
<div id="storage-results"></div> <div id="storage-results"></div>
</div> </div>
@@ -1675,15 +1682,20 @@
} }
// ---- Storage Usage ---- // ---- Storage Usage ----
function scanStorageUsage() { let storagePathStack = [];
function scanStorageUsage(path) {
const btn = document.getElementById('storage-scan-btn'); const btn = document.getElementById('storage-scan-btn');
const status = document.getElementById('storage-status'); const status = document.getElementById('storage-status');
const results = document.getElementById('storage-results'); const results = document.getElementById('storage-results');
const breadcrumb = document.getElementById('storage-breadcrumb');
if (!path) storagePathStack = [];
btn.disabled = true; btn.disabled = true;
status.style.color = 'var(--text-muted)'; status.style.color = 'var(--text-muted)';
status.textContent = 'Scanning… this reads every file, may take a moment.'; status.textContent = 'Scanning… this reads every file, may take a moment.';
results.innerHTML = ''; 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(r => r.json())
.then(data => { .then(data => {
btn.disabled = false; btn.disabled = false;
@@ -1692,6 +1704,9 @@
status.textContent = data.error; status.textContent = data.error;
return; return;
} }
breadcrumb.innerHTML = !data.is_root
? `<button class="btn btn-secondary btn-sm" onclick="storageGoBack()">${'←'} Back</button> <span style="color: var(--text-muted); font-size: 0.85em;">${escapeHtml(data.path)}</span>`
: '';
const entries = data.entries || []; const entries = data.entries || [];
if (!entries.length) { if (!entries.length) {
status.style.color = 'var(--text-muted)'; status.style.color = 'var(--text-muted)';
@@ -1702,7 +1717,7 @@
status.textContent = `Total: ${data.total_human}`; status.textContent = `Total: ${data.total_human}`;
const maxBytes = Math.max(...entries.map(e => e.bytes), 1); const maxBytes = Math.max(...entries.map(e => e.bytes), 1);
results.innerHTML = entries.map(e => ` results.innerHTML = entries.map(e => `
<div class="storage-row"> <div class="storage-row${e.is_dir ? ' storage-row-clickable' : ''}" ${e.is_dir ? `onclick="storageDrillDown('${e.path.replace(/'/g, "\\'")}')"` : ''}>
<span class="storage-row-name">${escapeHtml(e.name)}</span> <span class="storage-row-name">${escapeHtml(e.name)}</span>
<div class="storage-bar-track"><div class="storage-bar-fill" style="width: ${Math.round((e.bytes / maxBytes) * 100)}%;"></div></div> <div class="storage-bar-track"><div class="storage-bar-fill" style="width: ${Math.round((e.bytes / maxBytes) * 100)}%;"></div></div>
<span class="storage-row-size">${escapeHtml(e.human)}</span> <span class="storage-row-size">${escapeHtml(e.human)}</span>
@@ -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 ---- // ---- Duplicate Courses ----
function scanDuplicates() { function scanDuplicates() {
const btn = document.getElementById('dup-scan-btn'); const btn = document.getElementById('dup-scan-btn');
@@ -1918,9 +1948,9 @@
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
btn.disabled = false; 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 = []; 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] || [])) { for (const entry of (data[category] || [])) {
items.push({ path: entry.path, name: entry.name, category, label: categoryLabels[category] }); items.push({ path: entry.path, name: entry.name, category, label: categoryLabels[category] });
} }