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:
+137
-3
@@ -1,7 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
:root {
|
||||
--bg-primary: #1a1a1a;
|
||||
@@ -149,8 +154,56 @@
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.content {
|
||||
margin: 20px 0;
|
||||
.speed-controls {
|
||||
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 {
|
||||
margin: 20px 0;
|
||||
@@ -271,6 +324,15 @@
|
||||
</audio>
|
||||
{% 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 %}
|
||||
<h3>Content</h3>
|
||||
{% for text_file in lesson.text_files %}
|
||||
@@ -287,6 +349,13 @@
|
||||
{% endif %}
|
||||
</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">
|
||||
{% if 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) {
|
||||
let lastSaveTime = 0;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user