diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03208f7..eb34210 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+2026-08-26 13:12 UTC — Add course outline sidebar to the lesson page
2026-08-25 12:25 UTC — Remove unreliable duplicate-lesson-file matching
2026-08-25 00:33 UTC — Widen thumbnail sampling window past platform bumpers
2026-08-25 00:26 UTC — Fix race condition in thumbnail candidate generation
diff --git a/README.md b/README.md
index 142fcbc..59d3b55 100644
--- a/README.md
+++ b/README.md
@@ -225,6 +225,14 @@ silently stay blank instead of erroring.
**Playback & progress**
- Video/audio player with resize, playback-speed presets, and resume-from-
last-position
+- Course outline sidebar on the lesson page: every section and lesson in
+ the course (videos and documents alike - a standalone PDF/doc is its own
+ entry, same as a video), one click to jump anywhere without backing out
+ to the course page first. The current lesson's section opens
+ automatically and scrolls into view; sticky and independently
+ scrollable on desktop, stacks below the player on narrow viewports.
+ Shares its rendering with the loaded-course dashboard view (`templates/
+ _course_tree.html`), so the two always look and behave the same.
- Auto-play next lesson when one ends, with a cancelable few-second
countdown - on by default, toggle it off in Settings → Video Player
- Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek
diff --git a/VERSION b/VERSION
index 8658d3e..159c086 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2026-08-25 12:25 UTC — remove unreliable duplicate-lesson-file matching
+2026-08-26 13:12 UTC — add course outline sidebar to the lesson page
diff --git a/offlineu_core.py b/offlineu_core.py
index c0bb4ae..48fc718 100644
--- a/offlineu_core.py
+++ b/offlineu_core.py
@@ -4554,8 +4554,16 @@ def view_lesson(lesson_path: str):
if not current_course:
return redirect(url_for('index'))
- # Find the lesson in the tree, and the section (DirectoryNode) it belongs to
- lesson, section_node = find_lesson_in_tree(current_course.root_node, lesson_path)
+ # Populates the whole tree's Lesson objects (completed/progress_seconds/
+ # duration_seconds) from disk - needed for the course-outline sidebar,
+ # which (like the dashboard's tree) reads those fields directly rather
+ # than loading progress per-row.
+ ProgressTracker.apply_progress_to_tree(current_course)
+
+ # Find the lesson in the tree (the section/DirectoryNode it belongs to
+ # isn't needed here anymore - the sidebar shows the whole course, not
+ # just this lesson's own section)
+ lesson, _ = find_lesson_in_tree(current_course.root_node, lesson_path)
if not lesson:
return redirect(url_for('index'))
@@ -4603,7 +4611,6 @@ def view_lesson(lesson_path: str):
initial_seek_seconds=seek_seconds,
outline_topic_id=lesson_progress.get('outline_topic_id', ''),
outline_topic_name=lesson_progress.get('outline_topic_name', ''),
- section_lessons=get_section_lessons(current_course, section_node, lesson),
prev_lesson=prev_lesson,
next_lesson=next_lesson)
@@ -4656,36 +4663,6 @@ def find_lesson_in_tree(node: DirectoryNode, target_path: str) -> Tuple[Optional
return None, None
-def get_section_lessons(course: Course, section_node: DirectoryNode, current_lesson: Lesson) -> List[Dict[str, Any]]:
- """
- The sibling lessons in current_lesson's own section, for the lesson
- page's "up next in this section" list. view_lesson() isn't on the
- apply_progress_to_tree() code path, so - like that route already does
- for the current lesson's own notes/progress - this reads progress
- directly from the progress file rather than relying on Lesson fields.
- """
- progress = ProgressTracker.load_progress(course)
- siblings = []
- for sibling in section_node.lessons:
- key = _resolve_lesson_progress_key(course, sibling, progress)
- entry = progress.get(key, {}) if key else {}
- completed = entry.get('completed', False)
- progress_seconds = entry.get('progress_seconds', 0)
- duration_seconds = entry.get('duration_seconds', 0)
- percent_watched = 100 if completed else (
- round(100 * progress_seconds / duration_seconds) if duration_seconds and progress_seconds else 0
- )
- siblings.append({
- 'title': sibling.title,
- 'url': get_lesson_url(sibling, course.path),
- 'lesson_type': sibling.lesson_type,
- 'completed': completed,
- 'percent_watched': percent_watched,
- 'is_current': sibling is current_lesson,
- })
- return siblings
-
-
def get_all_lessons(node: DirectoryNode) -> List[Tuple[str, Lesson]]:
"""Get all lessons from the tree with their paths"""
lessons = []
diff --git a/templates/_course_tree.html b/templates/_course_tree.html
new file mode 100644
index 0000000..9f5120e
--- /dev/null
+++ b/templates/_course_tree.html
@@ -0,0 +1,78 @@
+{% import '_icons.html' as icons %}
+{#
+ Shared recursive course-outline renderer - one section/lesson tree
+ markup used by both the loaded-course dashboard view and the lesson
+ page's sidebar, so the two stay visually and behaviorally in sync
+ instead of drifting apart as separate copies. Needs `toggleTree()`
+ (JS) and the .tree-*/.lesson-*/.status-icon/.watched-badge CSS to be
+ defined by whichever page imports this - see course_dashboard.html
+ for the canonical versions.
+
+ `current_lesson` (a Lesson object, not a path string) is optional -
+ when given, the matching row gets a `current` class instead of being
+ a plain click target, and stays unclickable since you're already
+ there. Path-string comparison isn't used for this because the lesson
+ page's incoming URL can be in several different formats (see
+ find_lesson_in_tree) - comparing lesson.path (the raw absolute
+ filesystem path, always unique and unambiguous) sidesteps all of that.
+#}
+{% macro render_tree_node(node, course_path, current_lesson=none, depth=0) %}
+{% set stats = section_stats(node) %}
+