Add Resume button, Surprise Me random pick, and bulk select

Three quality-of-life additions to the Library browser and course
pages:

- A course page shows a "Resume: <lesson>" button when there's a
  last-visited lesson, using Course.last_accessed_path (already
  populated by apply_progress_to_tree, just not surfaced before) -
  no more scrolling the tree to find where you left off.

- "Surprise Me" picks a random not-yet-fully-watched course and loads
  it, for when there's too much library to decide what to watch.
  Reuses the already-cached get_all_course_dirs() and the same
  per-course completed/total shape _scan_library_activity() already
  computes for Stale Courses - no new scanning.

- A select-mode toggle in the Library browser adds checkboxes to every
  row/card (list and grid) with a bulk-action bar to hide or queue
  several courses/folders at once, via two new endpoints
  (/api/hidden-paths/bulk, /api/next-up/bulk) that reuse the existing
  single-item set_path_hidden()/set_path_queued() in a loop. Selection
  is scoped to the current folder view and clears on navigation.

Also fixes runTranscriptSearch() going silently blank on zero results
instead of showing a "no matches" message, and updates the Help page
to cover all of this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 15:06:29 -04:00
co-authored by Claude Sonnet 5
parent 8f6b00280f
commit 9e7dc85558
3 changed files with 277 additions and 15 deletions
+68 -1
View File
@@ -7,6 +7,7 @@ Enhanced version with dynamic subdirectory navigation
import os import os
import json import json
import mimetypes import mimetypes
import random
import re import re
import sys import sys
import argparse import argparse
@@ -1120,6 +1121,19 @@ def _scan_library_activity() -> Dict[str, Any]:
} }
def get_random_incomplete_course() -> Optional[Path]:
"""A random course for the dashboard's "Surprise Me" pick - excludes
courses that are already fully watched where possible, falling back to
the whole library if everything's done."""
all_dirs = get_all_course_dirs()
if not all_dirs:
return None
scan = _scan_library_activity()
fully_completed = {c['path'] for c in scan['courses'] if c['total'] > 0 and c['completed'] >= c['total']}
candidates = [d for d in all_dirs if str(d) not in fully_completed]
return random.choice(candidates or all_dirs)
def format_library_stats(scan: Dict[str, Any]) -> Dict[str, Any]: def format_library_stats(scan: Dict[str, Any]) -> Dict[str, Any]:
"""Library-wide overview for the dashboard's stats card, from a _scan_library_activity() result.""" """Library-wide overview for the dashboard's stats card, from a _scan_library_activity() result."""
return { return {
@@ -1827,13 +1841,20 @@ def index():
if stats.get('total_duration_seconds'): if stats.get('total_duration_seconds'):
stats['remaining_display'] = format_duration(stats['remaining_seconds']) stats['remaining_display'] = format_duration(stats['remaining_seconds'])
resume_lesson = None
if current_course.last_accessed_path:
lesson, _ = find_lesson_in_tree(current_course.root_node, current_course.last_accessed_path)
if lesson:
resume_lesson = {'title': lesson.title, 'url': get_lesson_url(lesson, current_course.path)}
return render_template('course_dashboard.html', return render_template('course_dashboard.html',
course=current_course, course=current_course,
stats=stats, stats=stats,
continue_watching=continue_watching, continue_watching=continue_watching,
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(),
resume_lesson=resume_lesson)
@app.route('/browse') @app.route('/browse')
@@ -2037,6 +2058,24 @@ def set_hidden_path_api():
return jsonify({'success': True, 'hidden_paths': updated}) return jsonify({'success': True, 'hidden_paths': updated})
@app.route('/api/hidden-paths/bulk', methods=['POST'])
def bulk_set_hidden_paths_api():
"""Hide or un-hide several courses/directories at once."""
data = request.json or {}
paths = data.get('paths') or []
hidden = bool(data.get('hidden', True))
if not isinstance(paths, list) or not paths:
return jsonify({'error': 'paths is required'}), 400
library_root = os.path.abspath(get_library_root())
updated = get_hidden_paths()
for path in paths:
target = os.path.abspath(path)
if target == library_root or target.startswith(library_root + os.sep):
updated = set_path_hidden(target, hidden)
return jsonify({'success': True, 'hidden_paths': updated})
def validate_new_name(new_name: str) -> Optional[str]: def validate_new_name(new_name: str) -> Optional[str]:
"""Validate a proposed directory name; returns an error message, or None if valid.""" """Validate a proposed directory name; returns an error message, or None if valid."""
if not new_name: if not new_name:
@@ -2302,6 +2341,17 @@ def load_course():
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@app.route('/random-pick')
def random_pick():
""""Surprise Me" - load a random not-yet-fully-watched course and land on its dashboard."""
global current_course
course_dir = get_random_incomplete_course()
if not course_dir:
return redirect(url_for('index'))
current_course = get_course_tree(str(course_dir))
return redirect(url_for('index'))
@app.route('/recent/open') @app.route('/recent/open')
def open_recent(): def open_recent():
""" """
@@ -2383,6 +2433,23 @@ def set_next_up_api():
return jsonify({'success': True, 'next_up': updated}) return jsonify({'success': True, 'next_up': updated})
@app.route('/api/next-up/bulk', methods=['POST'])
def bulk_set_next_up_api():
"""Add several courses to the Next Up queue at once."""
data = request.json or {}
paths = data.get('paths') or []
if not isinstance(paths, list) or not paths:
return jsonify({'error': 'paths is required'}), 400
library_root = os.path.abspath(get_library_root())
updated = get_next_up_paths()
for path in paths:
target = os.path.abspath(path)
if target == library_root or target.startswith(library_root + os.sep):
updated = set_path_queued(target, True)
return jsonify({'success': True, 'next_up': updated})
@app.route('/api/next-up/reorder', methods=['POST']) @app.route('/api/next-up/reorder', methods=['POST'])
def reorder_next_up_api(): def reorder_next_up_api():
"""Replace the Next Up order wholesale, from the client's up/down-reordered list.""" """Replace the Next Up order wholesale, from the client's up/down-reordered list."""
+153 -6
View File
@@ -248,6 +248,25 @@
background: #555; background: #555;
} }
.resume-btn {
display: block;
width: 100%;
text-align: center;
margin-top: 12px;
font-size: 1.05em;
font-weight: 600;
box-sizing: border-box;
}
.surprise-me-btn {
display: block;
width: 100%;
text-align: center;
font-size: 1.05em;
font-weight: 600;
box-sizing: border-box;
}
.progress-bar { .progress-bar {
background: #444; background: #444;
border-radius: 10px; border-radius: 10px;
@@ -653,6 +672,32 @@
color: white; color: white;
border-color: var(--accent); border-color: var(--accent);
} }
#library-select-btn {
border-radius: var(--radius);
margin-left: 4px;
}
.bulk-action-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
padding: 10px;
background: var(--bg-tertiary);
border-radius: var(--radius);
}
.bulk-selected-count {
font-size: 0.9em;
color: var(--text-muted);
margin-right: auto;
}
.select-checkbox {
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
}
.library-grid { .library-grid {
display: grid; display: grid;
@@ -881,6 +926,9 @@
<div class="container"> <div class="container">
<div class="card"> <div class="card">
<h2 class="course-name-heading">{{ course.name }}</h2> <h2 class="course-name-heading">{{ course.name }}</h2>
{% if resume_lesson %}
<a class="btn resume-btn" href="/lesson/{{ resume_lesson.url }}">▶ Resume: {{ resume_lesson.title }}</a>
{% endif %}
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; flex-wrap: wrap; gap: 10px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; flex-wrap: wrap; gap: 10px;">
<div> <div>
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed <strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
@@ -1028,6 +1076,10 @@
{% endmacro %} {% endmacro %}
{% if library_stats and library_stats.total_courses %} {% if library_stats and library_stats.total_courses %}
<div class="container">
<a class="btn surprise-me-btn" href="/random-pick">🎲 Surprise Me</a>
</div>
<div class="container"> <div class="container">
<div class="card"> <div class="card">
<h2 class="collapsible-header" data-card-id="library-stats">📊 Library Stats</h2> <h2 class="collapsible-header" data-card-id="library-stats">📊 Library Stats</h2>
@@ -1160,11 +1212,19 @@
<button type="button" class="view-toggle-btn" id="library-view-grid-btn" <button type="button" class="view-toggle-btn" id="library-view-grid-btn"
onclick="setLibraryViewMode('grid')" title="Grid view"></button> onclick="setLibraryViewMode('grid')" title="Grid view"></button>
</div> </div>
<button type="button" class="view-toggle-btn" id="library-select-btn"
onclick="toggleSelectionMode()" title="Select multiple"></button>
</div> </div>
<label class="transcript-search-toggle"> <label class="transcript-search-toggle">
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)"> <input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
Also search transcripts Also search transcripts
</label> </label>
<div id="bulk-action-bar" class="bulk-action-bar" style="display: none;">
<span id="bulk-selected-count" class="bulk-selected-count"></span>
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkHideSelected()">🚫 Hide Selected</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkQueueSelected()">📌 Add to Next Up</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleSelectionMode()">Cancel</button>
</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 id="transcript-results"></div> <div id="transcript-results"></div>
@@ -1268,6 +1328,7 @@
function fetchLibraryLevel(path) { function fetchLibraryLevel(path) {
searchActive = false; searchActive = false;
clearSelection(); // selection is scoped to the current folder view
const searchInput = document.getElementById('library-search-input'); const searchInput = document.getElementById('library-search-input');
if (searchInput) searchInput.value = ''; if (searchInput) searchInput.value = '';
const transcriptResults = document.getElementById('transcript-results'); const transcriptResults = document.getElementById('transcript-results');
@@ -1379,13 +1440,92 @@
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems)); if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
} }
// ---- Bulk select (hide / queue several courses or folders at once) ----
let selectionMode = false;
let selectedPaths = new Set();
function selectableAttrs(item) {
if (!selectionMode) return '';
const safePath = item.path.replace(/'/g, "\\'");
const checked = selectedPaths.has(item.path) ? 'checked' : '';
return `<input type="checkbox" class="select-checkbox" onclick="event.stopPropagation(); toggleSelection('${safePath}')" ${checked}>`;
}
function toggleSelectionMode() {
selectionMode = !selectionMode;
if (!selectionMode) selectedPaths.clear();
const btn = document.getElementById('library-select-btn');
if (btn) btn.classList.toggle('active', selectionMode);
updateBulkActionBar();
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
}
function toggleSelection(path) {
if (selectedPaths.has(path)) selectedPaths.delete(path);
else selectedPaths.add(path);
updateBulkActionBar();
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
}
function clearSelection() {
selectionMode = false;
selectedPaths.clear();
const btn = document.getElementById('library-select-btn');
if (btn) btn.classList.remove('active');
updateBulkActionBar();
}
function updateBulkActionBar() {
const bar = document.getElementById('bulk-action-bar');
const count = document.getElementById('bulk-selected-count');
if (!bar) return;
bar.style.display = selectedPaths.size > 0 ? 'flex' : 'none';
if (count) count.textContent = `${selectedPaths.size} selected`;
}
function bulkHideSelected() {
const paths = Array.from(selectedPaths);
fetch('/api/hidden-paths/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: paths, hidden: true })
})
.then(r => r.json())
.then(() => {
clearSelection();
fetchLibraryLevel(libraryTrail.length ? libraryTrail[libraryTrail.length - 1].path : null);
})
.catch(() => {});
}
function bulkQueueSelected() {
const coursePaths = Array.from(selectedPaths).filter(p => {
const item = (lastLibraryItems || []).find(i => i.path === p);
return item && item.type === 'course';
});
if (!coursePaths.length) {
alert('Select at least one course (not a folder) to add to Next Up.');
return;
}
fetch('/api/next-up/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: coursePaths })
})
.then(r => r.json())
.then(() => clearSelection())
.catch(() => {});
}
function courseCardHtml(item) { function courseCardHtml(item) {
const safePath = item.path.replace(/'/g, "\\'"); const safePath = item.path.replace(/'/g, "\\'");
const iconHtml = item.has_thumbnail const iconHtml = item.has_thumbnail
? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackIcon('🎓'))">` ? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackIcon('🎓'))">`
: `<span>🎓</span>`; : `<span>🎓</span>`;
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
return ` return `
<div class="library-grid-card" onclick="loadCoursePath('${safePath}')"> <div class="library-grid-card" onclick="${clickHandler}">
${selectableAttrs(item)}
<div class="grid-card-thumb-wrap">${iconHtml}</div> <div class="grid-card-thumb-wrap">${iconHtml}</div>
<div class="grid-card-name">${item.name}</div> <div class="grid-card-name">${item.name}</div>
<div class="grid-card-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</div> <div class="grid-card-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</div>
@@ -1396,8 +1536,10 @@
function directoryCardHtml(item) { function directoryCardHtml(item) {
const safePath = item.path.replace(/'/g, "\\'"); const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'"); const safeName = item.name.replace(/'/g, "\\'");
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`;
return ` return `
<div class="library-grid-card" onclick="enterLibraryDir('${safePath}', '${safeName}')"> <div class="library-grid-card" onclick="${clickHandler}">
${selectableAttrs(item)}
<div class="grid-card-thumb-wrap">📁</div> <div class="grid-card-thumb-wrap">📁</div>
<div class="grid-card-name">${item.name}</div> <div class="grid-card-name">${item.name}</div>
</div> </div>
@@ -1409,9 +1551,11 @@
const iconHtml = item.has_thumbnail const iconHtml = item.has_thumbnail
? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('🎓'))">` ? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('🎓'))">`
: `<span class="lesson-icon">🎓</span>`; : `<span class="lesson-icon">🎓</span>`;
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
return ` return `
<div class="lesson-item" onclick="loadCoursePath('${safePath}')"> <div class="lesson-item" onclick="${clickHandler}">
<div class="lesson-title"> <div class="lesson-title">
${selectableAttrs(item)}
${iconHtml} ${iconHtml}
<span class="lesson-name">${item.name}</span> <span class="lesson-name">${item.name}</span>
</div> </div>
@@ -1426,9 +1570,11 @@
function directoryRowHtml(item) { function directoryRowHtml(item) {
const safePath = item.path.replace(/'/g, "\\'"); const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'"); const safeName = item.name.replace(/'/g, "\\'");
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`;
return ` return `
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')"> <div class="tree-header directory" onclick="${clickHandler}">
<div class="tree-title"> <div class="tree-title">
${selectableAttrs(item)}
<span class="tree-icon">📁</span> <span class="tree-icon">📁</span>
<span class="tree-name">${item.name}</span> <span class="tree-name">${item.name}</span>
</div> </div>
@@ -1472,6 +1618,7 @@
function runLibrarySearch(query) { function runLibrarySearch(query) {
searchActive = true; searchActive = true;
clearSelection(); // selection is scoped to the current folder view
const container = document.getElementById('library-groups'); const container = document.getElementById('library-groups');
const transcriptContainer = document.getElementById('transcript-results'); const transcriptContainer = document.getElementById('transcript-results');
container.innerHTML = skeletonRowsHtml(2); container.innerHTML = skeletonRowsHtml(2);
@@ -1508,7 +1655,7 @@
.then(data => { .then(data => {
const results = data.results || []; const results = data.results || [];
if (!results.length) { if (!results.length) {
transcriptContainer.innerHTML = ''; transcriptContainer.innerHTML = '<p style="color:var(--text-muted); padding: 6px 0;">No matching transcripts.</p>';
return; return;
} }
transcriptContainer.innerHTML = `<div style="font-weight:600; margin: 10px 0 8px; font-size: 0.9em; color: var(--text-muted);">In transcripts</div>` + transcriptContainer.innerHTML = `<div style="font-weight:600; margin: 10px 0 8px; font-size: 0.9em; color: var(--text-muted);">In transcripts</div>` +
@@ -1521,7 +1668,7 @@
`).join(''); `).join('');
}) })
.catch(() => { .catch(() => {
transcriptContainer.innerHTML = ''; transcriptContainer.innerHTML = '<p style="color:var(--error);">Transcript search failed.</p>';
}); });
} }
+56 -8
View File
@@ -142,22 +142,70 @@
<h1>Help</h1> <h1>Help</h1>
<div class="card"> <div class="card">
<h3>How to Use OfflineU</h3> <h3>Getting Started</h3>
<ul> <ul>
<li><strong>Prepare your course files</strong> in a directory structure</li> <li><strong>Prepare your course files</strong> in a directory structure</li>
<li><strong>Browse your library</strong> from the main page, or enter a path manually</li> <li><strong>Browse your library</strong> from the main page, or enter a path manually in Settings</li>
<li><strong>Click a course</strong> to load it</li> <li><strong>Click a course</strong> to load it - folders drill down, courses open directly</li>
<li><strong>Start learning!</strong> Your progress will be saved automatically</li> <li><strong>Start learning!</strong> Progress, notes, and completion are all saved automatically</li>
<li>Not sure what to watch? <strong>🎲 Surprise Me</strong> at the top of the dashboard picks a random course you haven't finished</li>
</ul>
</div>
<div class="card">
<h3>Watching a Lesson</h3>
<ul>
<li><strong>Space</strong> - play/pause</li>
<li><strong>← / →</strong> - seek back/forward 10 seconds</li>
<li><strong>↑ / ↓</strong> - volume up/down</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>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>The <strong>Lessons in this section</strong> list under the player jumps between other lessons in the same folder without leaving the page</li>
</ul>
</div>
<div class="card">
<h3>Notes</h3>
<ul>
<li>Every note is stamped with the video's timestamp when you save it - click a timestamp to jump straight to that moment</li>
<li>Press <strong>N</strong> anywhere on a lesson page to capture one without reaching for the mouse</li>
<li>Edit or delete any note from the floating panel at the bottom of the lesson page</li>
<li><strong>Notes</strong> (in the footer) collects every note across your whole library in one searchable list</li>
<li><strong>Download Study Guide</strong> on a loaded course exports every note as one markdown file, organized by section</li>
<li>If you use <a href="/settings">Outline</a>, pick a topic on a lesson's notes to push them there automatically when you navigate away</li>
</ul>
</div>
<div class="card">
<h3>Finding Things</h3>
<ul>
<li>Search courses by name from the Library browser on the main page</li>
<li>Check <strong>Also search transcripts</strong> to search inside subtitle files (.srt/.vtt) too, if your courses have them</li>
<li>Switch between list and grid (poster) view with the toggle next to the search box - handy for browsing a folder with lots of courses by thumbnail</li>
<li><strong>Hide</strong> a course or whole folder from Settings → Manage Library without touching anything on disk</li>
<li>The ☑ button in the Library browser turns on <strong>select mode</strong> - pick several courses or folders at once to hide or add to Next Up together</li>
<li><strong>Bulk Rename</strong> (Settings) finds and replaces text across every course/folder name at once - useful for stripping a release-group suffix off a batch of downloads</li>
</ul>
</div>
<div class="card">
<h3>Settings</h3>
<ul>
<li>Pick a built-in theme or set a custom accent color, corner style, and card style</li>
<li>Adjust font, text size, page width, and spacing density</li>
<li>Set the default library folder, and use <strong>Refresh Library</strong> if you've just added or removed files directly on disk and don't want to wait for it to notice</li>
<li><strong>Download Backup</strong> exports your settings, hidden-path choices, recently-viewed history, and every course's progress/notes as a zip - not the course files themselves</li>
</ul> </ul>
</div> </div>
<div class="card"> <div class="card">
<h3>Supported File Types</h3> <h3>Supported File Types</h3>
<ul> <ul>
<li><strong>Videos:</strong> .mp4, .mkv, .avi, .mov, .webm</li> <li><strong>Videos:</strong> .mp4, .mkv, .avi, .mov, .webm, .m4v, .flv, .wmv</li>
<li><strong>Audio:</strong> .mp3, .wav, .m4a, .aac</li> <li><strong>Audio:</strong> .mp3, .wav, .m4a, .aac, .ogg, .flac</li>
<li><strong>Documents:</strong> .txt, .md, .html, .pdf</li> <li><strong>Documents:</strong> .txt, .md, .html, .htm, .pdf, .docx, .doc, .rtf</li>
<li><strong>Subtitles:</strong> .srt, .vtt</li> <li><strong>Subtitles:</strong> .srt, .vtt, .ass, .sub, .sbv</li>
</ul> </ul>
</div> </div>
</div> </div>