Add search, thumbnails, continue watching, playback speed, PWA install, sort, notes, and mark-watched
Eight usability additions on top of the mobile redesign: course cover-image thumbnails, a debounced library search, a Continue Watching section split from Recently Viewed, per-lesson playback speed control, an installable PWA manifest/icons, name sort in the library browser, per-lesson notes, and a bulk mark-course-as-watched action. Also fixes two real bugs found along the way: lesson_view.html was missing the viewport meta tag (so lesson pages weren't actually mobile-responsive), and update_lesson_progress always overwrote the whole progress entry, which would have silently deleted a saved note on the next routine autosave. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
it). If you rename your *currently loaded* course's folder, the app resets
|
||||||
to the library view rather than serving a stale path.
|
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 `<head>`.
|
||||||
|
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
|
## Known limitations still open
|
||||||
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
||||||
curation apply app-wide, not per-browser/per-user.
|
curation apply app-wide, not per-browser/per-user.
|
||||||
|
|||||||
+174
-5
@@ -25,6 +25,8 @@ AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'}
|
|||||||
SUBTITLE_EXTENSIONS = {'.srt', '.vtt', '.ass', '.sub', '.sbv'}
|
SUBTITLE_EXTENSIONS = {'.srt', '.vtt', '.ass', '.sub', '.sbv'}
|
||||||
TEXT_EXTENSIONS = {'.txt', '.md', '.html', '.htm', '.pdf', '.docx', '.doc', '.rtf'}
|
TEXT_EXTENSIONS = {'.txt', '.md', '.html', '.htm', '.pdf', '.docx', '.doc', '.rtf'}
|
||||||
QUIZ_INDICATORS = {'quiz', 'exam', 'test', 'assessment', 'exercise', 'assignment', 'homework'}
|
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
|
# 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
|
# 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
|
'library_path': '', # '' = use COURSES_LIBRARY_PATH/--library-path default
|
||||||
'video_width': '', # '' = responsive full-width; else last dragged size, in px
|
'video_width': '', # '' = responsive full-width; else last dragged size, in px
|
||||||
'video_height': '',
|
'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
|
# Bounds for the persisted video player size, to reject garbage values
|
||||||
@@ -68,6 +71,7 @@ SETTINGS_CHOICES = {
|
|||||||
'density': {'comfortable', 'compact'},
|
'density': {'comfortable', 'compact'},
|
||||||
'card_style': {'flat', 'elevated', 'bordered'},
|
'card_style': {'flat', 'elevated', 'bordered'},
|
||||||
'corner_radius': {'sharp', 'rounded', 'pill'},
|
'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
|
# 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
|
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(
|
_SECTION_NAME_RE = re.compile(
|
||||||
r'^(section|module|chapter|part|unit|lesson)\b', re.IGNORECASE
|
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,
|
'name': entry.name,
|
||||||
'path': entry_path_str,
|
'path': entry_path_str,
|
||||||
'media_files': media_count,
|
'media_files': media_count,
|
||||||
'hidden': is_hidden
|
'hidden': is_hidden,
|
||||||
|
'has_thumbnail': find_course_thumbnail(entry_path_str) is not None
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
if skip_hidden:
|
if skip_hidden:
|
||||||
@@ -709,8 +732,31 @@ def get_library_root() -> str:
|
|||||||
return LIBRARY_PATH
|
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')
|
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:
|
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)))
|
entry['percent_watched'] = max(0, min(100, round(100 * progress_seconds / duration_seconds)))
|
||||||
else:
|
else:
|
||||||
entry['percent_watched'] = 0
|
entry['percent_watched'] = 0
|
||||||
|
|
||||||
|
entry['has_thumbnail'] = find_course_thumbnail(entry.get('course_path', '')) is not None
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
@@ -840,11 +888,47 @@ class ProgressTracker:
|
|||||||
elif existing.get('duration_seconds'):
|
elif existing.get('duration_seconds'):
|
||||||
entry['duration_seconds'] = existing['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
|
progress[lesson_path] = entry
|
||||||
|
|
||||||
# Update last accessed path
|
# Update last accessed path
|
||||||
progress['last_accessed_path'] = lesson_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)
|
ProgressTracker.save_progress(course, progress)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -891,17 +975,37 @@ class ProgressTracker:
|
|||||||
current_course = None
|
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('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Main dashboard"""
|
"""Main dashboard"""
|
||||||
global current_course
|
global current_course
|
||||||
|
|
||||||
|
continue_watching, recent_views = _split_continue_watching(get_recent_views_for_display())
|
||||||
|
|
||||||
if current_course is None:
|
if current_course is None:
|
||||||
# Show dashboard with course selection option
|
# Show dashboard with course selection option
|
||||||
return render_template('course_dashboard.html',
|
return render_template('course_dashboard.html',
|
||||||
course=None,
|
course=None,
|
||||||
stats={'total_lessons': 0, 'completed_lessons': 0, 'completion_percentage': 0},
|
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
|
# Apply progress data to tree
|
||||||
ProgressTracker.apply_progress_to_tree(current_course)
|
ProgressTracker.apply_progress_to_tree(current_course)
|
||||||
@@ -910,7 +1014,8 @@ def index():
|
|||||||
return render_template('course_dashboard.html',
|
return render_template('course_dashboard.html',
|
||||||
course=current_course,
|
course=current_course,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
recent_views=get_recent_views_for_display())
|
continue_watching=continue_watching,
|
||||||
|
recent_views=recent_views)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/browse')
|
@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'])
|
@app.route('/api/hidden-paths', methods=['GET'])
|
||||||
def get_hidden_paths_api():
|
def get_hidden_paths_api():
|
||||||
"""List currently-hidden course/directory paths, with display names."""
|
"""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 for the cross-course "Recently Viewed" list on the dashboard
|
||||||
record_recent_view(current_course.name, current_course.path, lesson_path, lesson.title)
|
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',
|
return render_template('lesson_view.html',
|
||||||
course=current_course,
|
course=current_course,
|
||||||
lesson=lesson,
|
lesson=lesson,
|
||||||
lesson_path=lesson_path,
|
lesson_path=lesson_path,
|
||||||
|
lesson_note=note,
|
||||||
prev_lesson=prev_lesson,
|
prev_lesson=prev_lesson,
|
||||||
next_lesson=next_lesson)
|
next_lesson=next_lesson)
|
||||||
|
|
||||||
@@ -1367,6 +1506,36 @@ def update_progress():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/lesson-note', methods=['POST'])
|
||||||
|
def update_lesson_note_api():
|
||||||
|
"""API endpoint to save (or clear) a lesson's note"""
|
||||||
|
global current_course
|
||||||
|
|
||||||
|
if not current_course:
|
||||||
|
return jsonify({'error': 'No course loaded'}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
lesson_path = data.get('lesson_path')
|
||||||
|
note = (data.get('note') or '').strip()
|
||||||
|
if not lesson_path:
|
||||||
|
return jsonify({'error': 'lesson_path is required'}), 400
|
||||||
|
|
||||||
|
ProgressTracker.update_lesson_note(current_course, lesson_path, note)
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/course/mark-watched', methods=['POST'])
|
||||||
|
def mark_course_watched_api():
|
||||||
|
"""Mark every lesson in the currently loaded course as completed."""
|
||||||
|
global current_course
|
||||||
|
|
||||||
|
if not current_course:
|
||||||
|
return jsonify({'error': 'No course loaded'}), 400
|
||||||
|
|
||||||
|
ProgressTracker.mark_all_completed(current_course)
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/files/<path:filepath>')
|
@app.route('/files/<path:filepath>')
|
||||||
def serve_file(filepath):
|
def serve_file(filepath):
|
||||||
"""Serve course files"""
|
"""Serve course files"""
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+207
-43
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% if course %}{{ course.name }} - OfflineU{% else %}OfflineU{% endif %}</title>
|
<title>{% if course %}{{ course.name }} - OfflineU{% else %}OfflineU{% endif %}</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#007acc">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -455,6 +458,43 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lesson-thumb {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-sort-select {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.lesson-name {
|
.lesson-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -603,6 +643,12 @@
|
|||||||
<div class="progress-fill" style="width: {{ stats.completion_percentage }}%;"></div>
|
<div class="progress-fill" style="width: {{ stats.completion_percentage }}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if stats.total_lessons %}
|
||||||
|
<button class="btn btn-secondary" style="margin-top: 5px;" onclick="markCourseWatched()">
|
||||||
|
Mark all as completed
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if stats.last_accessed_path %}
|
{% if stats.last_accessed_path %}
|
||||||
<div class="last-accessed">
|
<div class="last-accessed">
|
||||||
<strong>Last Accessed:</strong> {{ stats.last_accessed_path }}
|
<strong>Last Accessed:</strong> {{ stats.last_accessed_path }}
|
||||||
@@ -674,30 +720,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% macro render_view_row(view) %}
|
||||||
|
<div class="lesson-item {% if view.completed %}completed{% endif %} {% if view.percent_watched and not view.completed %}in-progress{% endif %}"
|
||||||
|
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
|
||||||
|
<div class="lesson-title">
|
||||||
|
{% if view.has_thumbnail %}
|
||||||
|
<img class="lesson-thumb" src="/library/thumbnail?path={{ view.course_path | urlencode }}" alt=""
|
||||||
|
onerror="this.replaceWith(courseFallbackIcon('▶️'))">
|
||||||
|
{% else %}
|
||||||
|
<span class="lesson-icon">▶️</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="lesson-name">{{ view.lesson_title }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-meta">
|
||||||
|
{% if view.completed %}
|
||||||
|
<span class="status-icon completed">✓</span>
|
||||||
|
{% elif view.percent_watched %}
|
||||||
|
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="lesson-meta-text">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% if view.percent_watched %}
|
||||||
|
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% if continue_watching %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<h2>▶ Continue Watching</h2>
|
||||||
|
<div style="margin-top: 12px;">
|
||||||
|
{% for view in continue_watching %}
|
||||||
|
{{ render_view_row(view) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if recent_views %}
|
{% if recent_views %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>🕐 Recently Viewed</h2>
|
<h2>🕐 Recently Viewed</h2>
|
||||||
<div style="margin-top: 12px;">
|
<div style="margin-top: 12px;">
|
||||||
{% for view in recent_views %}
|
{% for view in recent_views %}
|
||||||
<div class="lesson-item {% if view.completed %}completed{% endif %} {% if view.percent_watched and not view.completed %}in-progress{% endif %}"
|
{{ render_view_row(view) }}
|
||||||
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
|
|
||||||
<div class="lesson-title">
|
|
||||||
<span class="lesson-icon">▶️</span>
|
|
||||||
<span class="lesson-name">{{ view.lesson_title }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="lesson-meta">
|
|
||||||
{% if view.completed %}
|
|
||||||
<span class="status-icon completed">✓</span>
|
|
||||||
{% elif view.percent_watched %}
|
|
||||||
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
|
|
||||||
{% endif %}
|
|
||||||
<span class="lesson-meta-text">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
{% if view.percent_watched %}
|
|
||||||
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -706,6 +773,14 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card" id="library-card">
|
<div class="card" id="library-card">
|
||||||
<h2>Your Courses</h2>
|
<h2>Your Courses</h2>
|
||||||
|
<div class="library-toolbar">
|
||||||
|
<input type="text" id="library-search-input" class="library-search-input"
|
||||||
|
placeholder="Search courses…" oninput="handleLibrarySearchInput(this.value)">
|
||||||
|
<select id="library-sort" class="library-sort-select" onchange="reapplySort()">
|
||||||
|
<option value="name-asc">Name (A→Z)</option>
|
||||||
|
<option value="name-desc">Name (Z→A)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<p id="library-path-bar" class="library-breadcrumb"></p>
|
<p id="library-path-bar" class="library-breadcrumb"></p>
|
||||||
<div id="library-groups" style="margin-top: 15px;"></div>
|
<div id="library-groups" style="margin-top: 15px;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -756,6 +831,9 @@
|
|||||||
// back up. Much less horizontal space wasted than the old
|
// back up. Much less horizontal space wasted than the old
|
||||||
// indent-per-level accordion, especially on narrow screens.
|
// indent-per-level accordion, especially on narrow screens.
|
||||||
let libraryTrail = []; // [{name, path}], root is implicit (not in the array)
|
let libraryTrail = []; // [{name, path}], root is implicit (not in the array)
|
||||||
|
let lastLibraryItems = null; // whatever's on screen right now (browse or search), for re-sort without re-fetching
|
||||||
|
let searchActive = false;
|
||||||
|
let searchDebounceTimer = null;
|
||||||
|
|
||||||
function loadLibrary() {
|
function loadLibrary() {
|
||||||
const libraryCard = document.getElementById('library-card');
|
const libraryCard = document.getElementById('library-card');
|
||||||
@@ -765,6 +843,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchLibraryLevel(path) {
|
function fetchLibraryLevel(path) {
|
||||||
|
searchActive = false;
|
||||||
|
const searchInput = document.getElementById('library-search-input');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
|
||||||
const container = document.getElementById('library-groups');
|
const container = document.getElementById('library-groups');
|
||||||
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
|
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
|
||||||
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
|
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
|
||||||
@@ -810,6 +892,7 @@
|
|||||||
const container = document.getElementById('library-groups');
|
const container = document.getElementById('library-groups');
|
||||||
const isRoot = libraryTrail.length === 0;
|
const isRoot = libraryTrail.length === 0;
|
||||||
if (!data.items || data.items.length === 0) {
|
if (!data.items || data.items.length === 0) {
|
||||||
|
lastLibraryItems = null;
|
||||||
const reason = (data.errors && data.errors.length)
|
const reason = (data.errors && data.errors.length)
|
||||||
? data.errors.join(' ')
|
? data.errors.join(' ')
|
||||||
: (isRoot ? `No course folders found under ${data.library_path}.` : 'No courses in here.');
|
: (isRoot ? `No course folders found under ${data.library_path}.` : 'No courses in here.');
|
||||||
@@ -817,31 +900,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = data.items.map(item => {
|
lastLibraryItems = data.items;
|
||||||
const safePath = item.path.replace(/'/g, "\\'");
|
renderItemRows(sortLibraryItems(data.items));
|
||||||
if (item.type === 'course') {
|
|
||||||
return `
|
|
||||||
<div class="lesson-item"
|
|
||||||
onclick="loadCoursePath('${safePath}')">
|
|
||||||
<div class="lesson-title">
|
|
||||||
<span class="lesson-icon">🎓</span>
|
|
||||||
<span class="lesson-name">${item.name}</span>
|
|
||||||
</div>
|
|
||||||
<span class="lesson-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
const safeName = item.name.replace(/'/g, "\\'");
|
|
||||||
return `
|
|
||||||
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')">
|
|
||||||
<div class="tree-title">
|
|
||||||
<span class="tree-icon">📁</span>
|
|
||||||
<span class="tree-name">${item.name}</span>
|
|
||||||
</div>
|
|
||||||
<span class="tree-toggle">›</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function enterLibraryDir(path, name) {
|
function enterLibraryDir(path, name) {
|
||||||
@@ -849,6 +909,97 @@
|
|||||||
fetchLibraryLevel(path);
|
fetchLibraryLevel(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback if a course's thumbnail image 404s/errors after the row
|
||||||
|
// already rendered (e.g. the file was removed on disk in between) -
|
||||||
|
// swaps back to the plain icon rather than showing a broken image.
|
||||||
|
function courseFallbackIcon(emoji) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'lesson-icon';
|
||||||
|
span.textContent = emoji;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
function courseRowHtml(item) {
|
||||||
|
const safePath = item.path.replace(/'/g, "\\'");
|
||||||
|
const iconHtml = item.has_thumbnail
|
||||||
|
? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('🎓'))">`
|
||||||
|
: `<span class="lesson-icon">🎓</span>`;
|
||||||
|
return `
|
||||||
|
<div class="lesson-item" onclick="loadCoursePath('${safePath}')">
|
||||||
|
<div class="lesson-title">
|
||||||
|
${iconHtml}
|
||||||
|
<span class="lesson-name">${item.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="lesson-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryRowHtml(item) {
|
||||||
|
const safePath = item.path.replace(/'/g, "\\'");
|
||||||
|
const safeName = item.name.replace(/'/g, "\\'");
|
||||||
|
return `
|
||||||
|
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')">
|
||||||
|
<div class="tree-title">
|
||||||
|
<span class="tree-icon">📁</span>
|
||||||
|
<span class="tree-name">${item.name}</span>
|
||||||
|
</div>
|
||||||
|
<span class="tree-toggle">›</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItemRows(items) {
|
||||||
|
document.getElementById('library-groups').innerHTML =
|
||||||
|
items.map(item => item.type === 'course' ? courseRowHtml(item) : directoryRowHtml(item)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortLibraryItems(items) {
|
||||||
|
const order = document.getElementById('library-sort').value;
|
||||||
|
const sorted = items.slice();
|
||||||
|
sorted.sort((a, b) => order === 'name-desc'
|
||||||
|
? b.name.localeCompare(a.name)
|
||||||
|
: a.name.localeCompare(b.name));
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reapplySort() {
|
||||||
|
if (!lastLibraryItems) return;
|
||||||
|
renderItemRows(sortLibraryItems(lastLibraryItems));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Search ----
|
||||||
|
function handleLibrarySearchInput(value) {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
const query = value.trim();
|
||||||
|
if (!query) {
|
||||||
|
fetchLibraryLevel(libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchDebounceTimer = setTimeout(() => runLibrarySearch(query), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runLibrarySearch(query) {
|
||||||
|
searchActive = true;
|
||||||
|
const container = document.getElementById('library-groups');
|
||||||
|
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching...</p>';
|
||||||
|
fetch(`/library/search?q=${encodeURIComponent(query)}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('library-path-bar').innerHTML =
|
||||||
|
`<span class="crumb current" style="max-width: none;">Search results for "${query}"</span>`;
|
||||||
|
lastLibraryItems = data.results;
|
||||||
|
if (!data.results.length) {
|
||||||
|
container.innerHTML = `<p style="color:#999; padding: 6px 0;">No courses match "${query}".</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderItemRows(sortLibraryItems(data.results));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
container.innerHTML = '<p style="color:#ff6b6b;">Search failed.</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function loadCoursePath(path) {
|
function loadCoursePath(path) {
|
||||||
fetch('/load_course', {
|
fetch('/load_course', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -865,6 +1016,19 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markCourseWatched() {
|
||||||
|
if (!confirm('Mark every lesson in this course as completed?')) return;
|
||||||
|
fetch('/api/course/mark-watched', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', loadLibrary);
|
document.addEventListener('DOMContentLoaded', loadLibrary);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Help - OfflineU</title>
|
<title>Help - OfflineU</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#007acc">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1a1a;
|
--bg-primary: #1a1a1a;
|
||||||
|
|||||||
+137
-3
@@ -1,7 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ lesson.title }} - {{ course.name }}</title>
|
<title>{{ lesson.title }} - {{ course.name }}</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#007acc">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1a1a;
|
--bg-primary: #1a1a1a;
|
||||||
@@ -149,8 +154,56 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.content {
|
.speed-controls {
|
||||||
margin: 20px 0;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.speed-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.speed-btn {
|
||||||
|
padding: 5px 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.speed-btn:hover {
|
||||||
|
background: var(--bg-tertiary-hover);
|
||||||
|
}
|
||||||
|
.speed-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.notes-section {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.lesson-note-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 90px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 0.95em;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.note-status {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 6px;
|
||||||
|
min-height: 1.2em;
|
||||||
}
|
}
|
||||||
.navigation {
|
.navigation {
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
@@ -271,6 +324,15 @@
|
|||||||
</audio>
|
</audio>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if lesson.video_file or lesson.audio_file %}
|
||||||
|
<div class="speed-controls" id="speed-controls">
|
||||||
|
<span class="speed-label">Speed:</span>
|
||||||
|
{% for speed in ['0.75', '1', '1.25', '1.5', '1.75', '2'] %}
|
||||||
|
<button type="button" class="speed-btn" data-speed="{{ speed }}" onclick="setPlaybackSpeed('{{ speed }}')">{{ speed }}x</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if lesson.text_files %}
|
{% if lesson.text_files %}
|
||||||
<h3>Content</h3>
|
<h3>Content</h3>
|
||||||
{% for text_file in lesson.text_files %}
|
{% for text_file in lesson.text_files %}
|
||||||
@@ -287,6 +349,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="notes-section">
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<textarea id="lesson-note" class="lesson-note-textarea"
|
||||||
|
placeholder="Jot something down about this lesson…">{{ lesson_note }}</textarea>
|
||||||
|
<span id="note-status" class="note-status"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="nav-buttons">
|
<div class="nav-buttons">
|
||||||
{% if prev_lesson %}
|
{% if prev_lesson %}
|
||||||
<a href="/lesson/{{ prev_lesson }}">
|
<a href="/lesson/{{ prev_lesson }}">
|
||||||
@@ -425,6 +494,71 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore the persisted playback speed and highlight the active
|
||||||
|
// button; clicking a button applies it immediately and persists
|
||||||
|
// it via /api/settings, mirroring the video-size pattern above.
|
||||||
|
if (activeMedia) {
|
||||||
|
fetch('/api/settings')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const speed = (data.settings || {}).playback_speed || '1';
|
||||||
|
activeMedia.playbackRate = parseFloat(speed);
|
||||||
|
highlightSpeedButton(speed);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightSpeedButton(speed) {
|
||||||
|
document.querySelectorAll('.speed-btn').forEach(function(btn) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.speed === speed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlaybackSpeed(speed) {
|
||||||
|
if (activeMedia) activeMedia.playbackRate = parseFloat(speed);
|
||||||
|
highlightSpeedButton(speed);
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playback_speed: speed })
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lesson notes: debounced autosave on typing, plus an immediate
|
||||||
|
// save on blur so navigating away doesn't lose the last edit.
|
||||||
|
const noteField = document.getElementById('lesson-note');
|
||||||
|
const noteStatus = document.getElementById('note-status');
|
||||||
|
let noteSaveTimeout = null;
|
||||||
|
|
||||||
|
function saveNote() {
|
||||||
|
if (!noteField) return;
|
||||||
|
fetch('/api/lesson-note', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ lesson_path: '{{ lesson_path }}', note: noteField.value })
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (noteStatus) {
|
||||||
|
noteStatus.textContent = 'Saved';
|
||||||
|
setTimeout(() => { noteStatus.textContent = ''; }, 2000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (noteStatus) noteStatus.textContent = 'Could not save note';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noteField) {
|
||||||
|
noteField.addEventListener('input', function() {
|
||||||
|
clearTimeout(noteSaveTimeout);
|
||||||
|
noteSaveTimeout = setTimeout(saveNote, 1000);
|
||||||
|
});
|
||||||
|
noteField.addEventListener('blur', function() {
|
||||||
|
clearTimeout(noteSaveTimeout);
|
||||||
|
saveNote();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (activeMedia) {
|
if (activeMedia) {
|
||||||
let lastSaveTime = 0;
|
let lastSaveTime = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Settings - OfflineU</title>
|
<title>Settings - OfflineU</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#007acc">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1a1a;
|
--bg-primary: #1a1a1a;
|
||||||
@@ -453,6 +456,16 @@
|
|||||||
</label>
|
</label>
|
||||||
<button class="btn btn-secondary" onclick="resetVideoSize()">Reset size</button>
|
<button class="btn btn-secondary" onclick="resetVideoSize()">Reset size</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<label for="playback_speed">Default playback speed
|
||||||
|
<span class="setting-desc">Applied when a lesson loads; still adjustable per-lesson</span>
|
||||||
|
</label>
|
||||||
|
<select id="playback_speed" data-setting="playback_speed">
|
||||||
|
{% for speed in ['0.75', '1', '1.25', '1.5', '1.75', '2'] %}
|
||||||
|
<option value="{{ speed }}" {% if settings.playback_speed == speed %}selected{% endif %}>{{ speed }}x</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
Reference in New Issue
Block a user