diff --git a/offlineu_core.py b/offlineu_core.py index 6b5c38d..779095a 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -7,6 +7,7 @@ Enhanced version with dynamic subdirectory navigation import os import json import mimetypes +import random import re import sys 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]: """Library-wide overview for the dashboard's stats card, from a _scan_library_activity() result.""" return { @@ -1827,13 +1841,20 @@ def index(): if stats.get('total_duration_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', course=current_course, stats=stats, continue_watching=continue_watching, recent_views=recent_views, has_note=bool(course_has_any_notes(current_course)), - is_queued=os.path.abspath(current_course.path) in get_next_up_paths()) + is_queued=os.path.abspath(current_course.path) in get_next_up_paths(), + resume_lesson=resume_lesson) @app.route('/browse') @@ -2037,6 +2058,24 @@ def set_hidden_path_api(): 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]: """Validate a proposed directory name; returns an error message, or None if valid.""" if not new_name: @@ -2302,6 +2341,17 @@ def load_course(): 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') def open_recent(): """ @@ -2383,6 +2433,23 @@ def set_next_up_api(): 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']) def reorder_next_up_api(): """Replace the Next Up order wholesale, from the client's up/down-reordered list.""" diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 310b128..b55e274 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -248,6 +248,25 @@ 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 { background: #444; border-radius: 10px; @@ -653,6 +672,32 @@ color: white; 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 { display: grid; @@ -881,6 +926,9 @@

{{ course.name }}

+ {% if resume_lesson %} + ▶ Resume: {{ resume_lesson.title }} + {% endif %}
{{ stats.completed_lessons }}/{{ stats.total_lessons }} lessons completed @@ -1028,6 +1076,10 @@ {% endmacro %} {% if library_stats and library_stats.total_courses %} + +

📊 Library Stats

@@ -1160,11 +1212,19 @@
+
+

@@ -1268,6 +1328,7 @@ function fetchLibraryLevel(path) { searchActive = false; + clearSelection(); // selection is scoped to the current folder view const searchInput = document.getElementById('library-search-input'); if (searchInput) searchInput.value = ''; const transcriptResults = document.getElementById('transcript-results'); @@ -1379,13 +1440,92 @@ 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 ``; + } + + 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) { const safePath = item.path.replace(/'/g, "\\'"); const iconHtml = item.has_thumbnail ? `` : `🎓`; + const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`; return ` -
+
+ ${selectableAttrs(item)}
${iconHtml}
${item.name}
${item.media_files} media file${item.media_files === 1 ? '' : 's'}
@@ -1396,8 +1536,10 @@ function directoryCardHtml(item) { const safePath = item.path.replace(/'/g, "\\'"); const safeName = item.name.replace(/'/g, "\\'"); + const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`; return ` -
+
+ ${selectableAttrs(item)}
📁
${item.name}
@@ -1409,9 +1551,11 @@ const iconHtml = item.has_thumbnail ? `` : `🎓`; + const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`; return ` -
+
+ ${selectableAttrs(item)} ${iconHtml} ${item.name}
@@ -1426,9 +1570,11 @@ function directoryRowHtml(item) { const safePath = item.path.replace(/'/g, "\\'"); const safeName = item.name.replace(/'/g, "\\'"); + const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `enterLibraryDir('${safePath}', '${safeName}')`; return ` -
+
+ ${selectableAttrs(item)} 📁 ${item.name}
@@ -1472,6 +1618,7 @@ function runLibrarySearch(query) { searchActive = true; + clearSelection(); // selection is scoped to the current folder view const container = document.getElementById('library-groups'); const transcriptContainer = document.getElementById('transcript-results'); container.innerHTML = skeletonRowsHtml(2); @@ -1508,7 +1655,7 @@ .then(data => { const results = data.results || []; if (!results.length) { - transcriptContainer.innerHTML = ''; + transcriptContainer.innerHTML = '

No matching transcripts.

'; return; } transcriptContainer.innerHTML = `
In transcripts
` + @@ -1521,7 +1668,7 @@ `).join(''); }) .catch(() => { - transcriptContainer.innerHTML = ''; + transcriptContainer.innerHTML = '

Transcript search failed.

'; }); } diff --git a/templates/help.html b/templates/help.html index 0b81e32..4b4184d 100644 --- a/templates/help.html +++ b/templates/help.html @@ -142,22 +142,70 @@

Help

-

How to Use OfflineU

+

Getting Started

  • Prepare your course files in a directory structure
  • -
  • Browse your library from the main page, or enter a path manually
  • -
  • Click a course to load it
  • -
  • Start learning! Your progress will be saved automatically
  • +
  • Browse your library from the main page, or enter a path manually in Settings
  • +
  • Click a course to load it - folders drill down, courses open directly
  • +
  • Start learning! Progress, notes, and completion are all saved automatically
  • +
  • Not sure what to watch? 🎲 Surprise Me at the top of the dashboard picks a random course you haven't finished
  • +
+
+ +
+

Watching a Lesson

+
    +
  • Space - play/pause
  • +
  • ← / → - seek back/forward 10 seconds
  • +
  • ↑ / ↓ - volume up/down
  • +
  • N - jump into the quick-capture note box (pauses the video)
  • +
  • Drag the video's bottom-right corner to resize it, and pick a default playback speed in Settings - both are remembered
  • +
  • Reopening a lesson resumes from where you left off, and the ▶ Resume button at the top of a loaded course jumps straight back to the last lesson you had open
  • +
  • The Lessons in this section list under the player jumps between other lessons in the same folder without leaving the page
  • +
+
+ +
+

Notes

+
    +
  • Every note is stamped with the video's timestamp when you save it - click a timestamp to jump straight to that moment
  • +
  • Press N anywhere on a lesson page to capture one without reaching for the mouse
  • +
  • Edit or delete any note from the floating panel at the bottom of the lesson page
  • +
  • Notes (in the footer) collects every note across your whole library in one searchable list
  • +
  • Download Study Guide on a loaded course exports every note as one markdown file, organized by section
  • +
  • If you use Outline, pick a topic on a lesson's notes to push them there automatically when you navigate away
  • +
+
+ +
+

Finding Things

+
    +
  • Search courses by name from the Library browser on the main page
  • +
  • Check Also search transcripts to search inside subtitle files (.srt/.vtt) too, if your courses have them
  • +
  • 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
  • +
  • Hide a course or whole folder from Settings → Manage Library without touching anything on disk
  • +
  • The ☑ button in the Library browser turns on select mode - pick several courses or folders at once to hide or add to Next Up together
  • +
  • Bulk Rename (Settings) finds and replaces text across every course/folder name at once - useful for stripping a release-group suffix off a batch of downloads
  • +
+
+ +
+

Settings

+
    +
  • Pick a built-in theme or set a custom accent color, corner style, and card style
  • +
  • Adjust font, text size, page width, and spacing density
  • +
  • Set the default library folder, and use Refresh Library if you've just added or removed files directly on disk and don't want to wait for it to notice
  • +
  • Download Backup exports your settings, hidden-path choices, recently-viewed history, and every course's progress/notes as a zip - not the course files themselves

Supported File Types

    -
  • Videos: .mp4, .mkv, .avi, .mov, .webm
  • -
  • Audio: .mp3, .wav, .m4a, .aac
  • -
  • Documents: .txt, .md, .html, .pdf
  • -
  • Subtitles: .srt, .vtt
  • +
  • Videos: .mp4, .mkv, .avi, .mov, .webm, .m4v, .flv, .wmv
  • +
  • Audio: .mp3, .wav, .m4a, .aac, .ogg, .flac
  • +
  • Documents: .txt, .md, .html, .htm, .pdf, .docx, .doc, .rtf
  • +
  • Subtitles: .srt, .vtt, .ass, .sub, .sbv