coursewatcher 1.0.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,94 +1,94 @@
1
1
  /**
2
2
  * Video Player Controls
3
3
  *
4
- * Handles keyboard shortcuts, playback controls, and progress auto-save.
4
+ * Handles Plyr initialization, keyboard shortcuts, progress auto-save,
5
+ * and autoplay countdown functionality.
5
6
  */
6
7
 
7
8
  (function () {
8
9
  'use strict';
9
10
 
10
11
  // Constants
11
- const SPEED_STEP = 0.25;
12
- const MIN_SPEED = 0.25;
13
- const MAX_SPEED = 4.0;
14
- const SEEK_SHORT = 5;
15
- const SEEK_MEDIUM = 10;
16
- const SEEK_LONG = 30;
17
12
  const SAVE_INTERVAL = 5000; // Save progress every 5 seconds
13
+ const AUTOPLAY_COUNTDOWN = 5; // Seconds before auto-navigating to next video
18
14
 
19
15
  // Elements
20
- const video = document.getElementById('videoPlayer');
21
- const speedDisplay = document.getElementById('speedDisplay');
22
- const speedDown = document.getElementById('speedDown');
23
- const speedUp = document.getElementById('speedUp');
24
- const seekBack5 = document.getElementById('seekBack5');
25
- const seekForward5 = document.getElementById('seekForward5');
26
- const seekForward10 = document.getElementById('seekForward10');
27
- const statusButtons = document.querySelectorAll('.status-btn');
16
+ const videoElement = document.getElementById('videoPlayer');
17
+
18
+ // Notes & Status
28
19
  const notesEditor = document.getElementById('notesEditor');
29
20
  const saveNotesBtn = document.getElementById('saveNotes');
30
21
  const notesSaveStatus = document.getElementById('notesSaveStatus');
22
+ const statusButtons = document.querySelectorAll('.status-btn');
31
23
 
32
- if (!video) return;
24
+ // Autoplay elements
25
+ const autoplayOverlay = document.getElementById('autoplayOverlay');
26
+ const countdownNumber = document.getElementById('countdownNumber');
27
+ const countdownProgress = document.getElementById('countdownProgress');
28
+ const nextVideoTitle = document.getElementById('nextVideoTitle');
29
+ const cancelAutoplayBtn = document.getElementById('cancelAutoplay');
33
30
 
34
- const videoId = video.dataset.videoId;
35
- const savedPosition = parseFloat(video.dataset.savedPosition) || 0;
31
+ if (!videoElement) return;
32
+
33
+ const videoId = videoElement.dataset.videoId;
34
+ const savedPosition = parseFloat(videoElement.dataset.savedPosition) || 0;
35
+ const nextVideoUrl = videoElement.dataset.nextVideoUrl;
36
36
 
37
37
  let saveTimeout = null;
38
38
  let lastSavedPosition = savedPosition;
39
+ let autoplayInterval = null;
40
+ let autoplayCountdown = AUTOPLAY_COUNTDOWN;
39
41
 
40
42
  // ==========================================
41
43
  // Initialization
42
44
  // ==========================================
43
45
 
44
- /**
45
- * Initialize player
46
- */
47
46
  function init() {
48
- // Restore saved position
49
- video.addEventListener('loadedmetadata', () => {
50
- if (savedPosition > 0 && savedPosition < video.duration) {
51
- video.currentTime = savedPosition;
47
+ // Initialize Plyr
48
+ const player = new Plyr('#videoPlayer', {
49
+ keyboard: { focused: true, global: true },
50
+ seekTime: 5,
51
+ controls: [
52
+ 'play-large', // The large play button in the center
53
+ 'restart', // Restart playback
54
+ 'rewind', // Rewind by the seek time (default 10 seconds)
55
+ 'play', // Play/pause playback
56
+ 'fast-forward', // Fast forward by the seek time (default 10 seconds)
57
+ 'progress', // The progress bar and scrubber for playback and buffering
58
+ 'current-time', // The current time of playback
59
+ 'duration', // The full duration of the media
60
+ 'mute', // Toggle mute
61
+ 'volume', // Volume control
62
+ 'captions', // Toggle captions
63
+ 'settings', // Settings menu
64
+ 'pip', // Picture-in-picture (currently Safari only)
65
+ 'airplay', // Airplay (currently Safari only)
66
+ 'fullscreen', // Toggle fullscreen
67
+ ]
68
+ });
69
+
70
+ // Restore saved position and autoplay
71
+ player.on('ready', () => {
72
+ if (savedPosition > 0) {
73
+ player.currentTime = savedPosition;
52
74
  }
53
- updateSpeedDisplay();
75
+
76
+ // Always try to autoplay
77
+ player.play().catch(() => {
78
+ // Autoplay might be blocked by browser on first visit, ignore
79
+ console.log('Autoplay was prevented by browser');
80
+ });
54
81
  });
55
82
 
56
- // Set up event listeners
57
- setupPlaybackControls();
83
+ // Setup Logic
58
84
  setupStatusControls();
59
- setupKeyboardShortcuts();
60
85
  setupNotesControls();
61
- setupProgressAutoSave();
62
- }
63
-
64
- // ==========================================
65
- // Playback Controls
66
- // ==========================================
67
-
68
- function setupPlaybackControls() {
69
- speedDown?.addEventListener('click', () => changeSpeed(-SPEED_STEP));
70
- speedUp?.addEventListener('click', () => changeSpeed(SPEED_STEP));
71
- seekBack5?.addEventListener('click', () => seek(-SEEK_SHORT));
72
- seekForward5?.addEventListener('click', () => seek(SEEK_SHORT));
73
- seekForward10?.addEventListener('click', () => seek(SEEK_MEDIUM));
74
- }
75
-
76
- function changeSpeed(delta) {
77
- let newSpeed = video.playbackRate + delta;
78
- newSpeed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
79
- newSpeed = Math.round(newSpeed * 100) / 100; // Fix floating point
80
- video.playbackRate = newSpeed;
81
- updateSpeedDisplay();
82
- }
83
-
84
- function updateSpeedDisplay() {
85
- if (speedDisplay) {
86
- speedDisplay.textContent = video.playbackRate.toFixed(2) + 'x';
87
- }
88
- }
86
+ setupProgressAutoSave(player);
87
+ setupAutoplay(player);
89
88
 
90
- function seek(seconds) {
91
- video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + seconds));
89
+ // Custom shortcuts not covered by Plyr (if any)
90
+ // Plyr covers Space, K, F, M, Arrow keys.
91
+ // We can add custom ones if needed, but standard ones are usually enough.
92
92
  }
93
93
 
94
94
  // ==========================================
@@ -123,111 +123,48 @@
123
123
  }
124
124
  }
125
125
 
126
- // ==========================================
127
- // Keyboard Shortcuts
128
- // ==========================================
129
-
130
- function setupKeyboardShortcuts() {
131
- document.addEventListener('keydown', (e) => {
132
- // Ignore if typing in notes
133
- if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') {
134
- return;
135
- }
136
-
137
- switch (e.key.toLowerCase()) {
138
- case ' ':
139
- e.preventDefault();
140
- video.paused ? video.play() : video.pause();
141
- break;
142
- case 'w':
143
- changeSpeed(-SPEED_STEP);
144
- break;
145
- case 'e':
146
- changeSpeed(SPEED_STEP);
147
- break;
148
- case 'j':
149
- seek(e.shiftKey ? -SEEK_LONG : -SEEK_SHORT);
150
- break;
151
- case 'k':
152
- seek(SEEK_SHORT);
153
- break;
154
- case 'l':
155
- seek(e.shiftKey ? SEEK_LONG : SEEK_MEDIUM);
156
- break;
157
- case 'arrowleft':
158
- seek(-SEEK_SHORT);
159
- break;
160
- case 'arrowright':
161
- seek(SEEK_SHORT);
162
- break;
163
- case 'arrowup':
164
- e.preventDefault();
165
- video.volume = Math.min(1, video.volume + 0.1);
166
- break;
167
- case 'arrowdown':
168
- e.preventDefault();
169
- video.volume = Math.max(0, video.volume - 0.1);
170
- break;
171
- case 'm':
172
- video.muted = !video.muted;
173
- break;
174
- case 'f':
175
- toggleFullscreen();
176
- break;
177
- }
178
- });
179
- }
180
-
181
- function toggleFullscreen() {
182
- if (document.fullscreenElement) {
183
- document.exitFullscreen();
184
- } else {
185
- video.requestFullscreen?.() || video.webkitRequestFullscreen?.();
186
- }
187
- }
188
-
189
126
  // ==========================================
190
127
  // Progress Auto-Save
191
128
  // ==========================================
192
129
 
193
- function setupProgressAutoSave() {
130
+ function setupProgressAutoSave(player) {
194
131
  // Save on pause
195
- video.addEventListener('pause', saveProgress);
132
+ player.on('pause', () => saveProgress(player));
196
133
 
197
- // Save on video end
198
- video.addEventListener('ended', () => {
199
- saveProgress();
134
+ // Save on video end (but don't update status here, autoplay handles it)
135
+ player.on('ended', () => {
136
+ saveProgress(player);
200
137
  updateStatus('completed');
201
138
  });
202
139
 
203
140
  // Periodic save during playback
204
- video.addEventListener('timeupdate', () => {
205
- const currentPos = Math.floor(video.currentTime);
141
+ player.on('timeupdate', () => {
142
+ const currentPos = Math.floor(player.currentTime);
206
143
 
207
144
  // Only save every 5 seconds of playback
208
145
  if (Math.abs(currentPos - lastSavedPosition) >= 5) {
209
- scheduleProgressSave();
146
+ scheduleProgressSave(player);
210
147
  }
211
148
  });
212
149
 
213
150
  // Save before page unload
214
151
  window.addEventListener('beforeunload', () => {
215
- saveProgressSync();
152
+ saveProgressSync(player);
216
153
  });
217
154
  }
218
155
 
219
- function scheduleProgressSave() {
156
+ function scheduleProgressSave(player) {
220
157
  if (saveTimeout) return;
221
158
 
222
159
  saveTimeout = setTimeout(() => {
223
- saveProgress();
160
+ saveProgress(player);
224
161
  saveTimeout = null;
225
162
  }, 1000);
226
163
  }
227
164
 
228
- async function saveProgress() {
229
- const position = video.currentTime;
230
- const duration = video.duration;
165
+ async function saveProgress(player) {
166
+ const position = player.currentTime;
167
+ const duration = player.duration;
231
168
 
232
169
  if (isNaN(position) || isNaN(duration)) return;
233
170
 
@@ -243,9 +180,9 @@
243
180
  }
244
181
  }
245
182
 
246
- function saveProgressSync() {
247
- const position = video.currentTime;
248
- const duration = video.duration;
183
+ function saveProgressSync(player) {
184
+ const position = player.currentTime;
185
+ const duration = player.duration;
249
186
 
250
187
  if (isNaN(position) || isNaN(duration)) return;
251
188
 
@@ -256,6 +193,111 @@
256
193
  );
257
194
  }
258
195
 
196
+ // ==========================================
197
+ // Autoplay Countdown
198
+ // ==========================================
199
+
200
+ function setupAutoplay(player) {
201
+ if (!nextVideoUrl || !autoplayOverlay) return;
202
+
203
+ // Get skip button
204
+ const skipToNextBtn = document.getElementById('skipToNext');
205
+
206
+ // Get next video title from queue
207
+ const nextVideoId = nextVideoUrl.split('/').pop();
208
+ const nextQueueItem = document.querySelector(`.queue-item[data-video-id="${nextVideoId}"]`);
209
+ const nextTitle = nextQueueItem ? nextQueueItem.dataset.videoTitle : 'Next Video';
210
+
211
+ // Set the title in the overlay
212
+ if (nextVideoTitle) {
213
+ nextVideoTitle.textContent = nextTitle;
214
+ }
215
+
216
+ // When video ends, start countdown
217
+ player.on('ended', () => {
218
+ startAutoplayCountdown();
219
+ });
220
+
221
+ // Skip button - navigate immediately
222
+ if (skipToNextBtn) {
223
+ skipToNextBtn.addEventListener('click', () => {
224
+ if (autoplayInterval) {
225
+ clearInterval(autoplayInterval);
226
+ }
227
+ window.location.href = nextVideoUrl;
228
+ });
229
+ }
230
+
231
+ // Cancel button
232
+ if (cancelAutoplayBtn) {
233
+ cancelAutoplayBtn.addEventListener('click', () => {
234
+ cancelAutoplayCountdown();
235
+ });
236
+ }
237
+
238
+ // ESC key to cancel
239
+ document.addEventListener('keydown', (e) => {
240
+ if (e.key === 'Escape' && !autoplayOverlay.classList.contains('hidden')) {
241
+ cancelAutoplayCountdown();
242
+ }
243
+ });
244
+ }
245
+
246
+ function startAutoplayCountdown() {
247
+ if (!nextVideoUrl) return;
248
+
249
+ autoplayCountdown = AUTOPLAY_COUNTDOWN;
250
+
251
+ // Show overlay
252
+ autoplayOverlay.classList.remove('hidden');
253
+
254
+ // Update countdown display
255
+ updateCountdownDisplay();
256
+
257
+ // Start countdown interval
258
+ autoplayInterval = setInterval(() => {
259
+ autoplayCountdown--;
260
+ updateCountdownDisplay();
261
+
262
+ if (autoplayCountdown <= 0) {
263
+ clearInterval(autoplayInterval);
264
+ // Navigate to next video with autoplay
265
+ window.location.href = nextVideoUrl;
266
+ }
267
+ }, 1000);
268
+ }
269
+
270
+ function updateCountdownDisplay() {
271
+ if (countdownNumber) {
272
+ countdownNumber.textContent = autoplayCountdown;
273
+ }
274
+
275
+ if (countdownProgress) {
276
+ // Calculate progress (circle circumference is 2 * PI * r = 2 * PI * 45 ≈ 283)
277
+ const circumference = 283;
278
+ const progress = (AUTOPLAY_COUNTDOWN - autoplayCountdown) / AUTOPLAY_COUNTDOWN;
279
+ const offset = circumference * progress;
280
+ countdownProgress.style.strokeDashoffset = offset;
281
+ }
282
+ }
283
+
284
+ function cancelAutoplayCountdown() {
285
+ if (autoplayInterval) {
286
+ clearInterval(autoplayInterval);
287
+ autoplayInterval = null;
288
+ }
289
+
290
+ // Hide overlay
291
+ if (autoplayOverlay) {
292
+ autoplayOverlay.classList.add('hidden');
293
+ }
294
+
295
+ // Reset progress ring
296
+ if (countdownProgress) {
297
+ countdownProgress.style.strokeDashoffset = 0;
298
+ }
299
+ }
300
+
259
301
  // ==========================================
260
302
  // Notes Controls
261
303
  // ==========================================
@@ -314,3 +356,4 @@
314
356
  init();
315
357
  }
316
358
  })();
359
+
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  /**
4
4
  * CourseWatcher CLI Entry Point
@@ -6,32 +6,34 @@
6
6
  * Main executable for the coursewatcher command.
7
7
  * Parses arguments and starts the web server.
8
8
  *
9
- * @module bin/coursewatcher
9
+ * @module cli
10
10
  */
11
11
 
12
12
  const { program } = require('commander');
13
13
  const path = require('path');
14
14
  const { version, description } = require('../package.json');
15
- const { startServer } = require('../src/server');
16
- const { log, success, error } = require('../src/utils/logger');
15
+ const { startServer } = require('./server');
16
+ const { log, success, error } = require('./utils/logger');
17
17
 
18
18
  program
19
19
  .name('coursewatcher')
20
20
  .description(description)
21
21
  .version(version)
22
22
  .argument('[path]', 'path to course directory', '.')
23
- .option('-p, --port <number>', 'server port', '3000')
23
+ .option('-p, --port <number>', 'server port')
24
24
  .option('--no-browser', 'do not open browser automatically')
25
25
  .action(async (coursePath, options) => {
26
26
  try {
27
27
  const absolutePath = path.resolve(coursePath);
28
- const port = parseInt(options.port, 10);
28
+ const isPortSpecified = !!options.port;
29
+ const port = isPortSpecified ? parseInt(options.port, 10) : 3000;
29
30
 
30
31
  log(`Starting CourseWatcher in: ${absolutePath}`);
31
32
 
32
33
  await startServer({
33
34
  coursePath: absolutePath,
34
35
  port,
36
+ allowFallback: !isPortSpecified,
35
37
  openBrowser: options.browser,
36
38
  });
37
39
  } catch (err) {
@@ -48,12 +48,14 @@ function createVideoRoutes(services) {
48
48
  const video = videoService.getVideoById(videoId);
49
49
  const adjacent = videoService.getAdjacentVideos(videoId);
50
50
  const notes = notesService.getNotes(videoId);
51
+ const queue = videoService.getQueueVideos(videoId);
51
52
 
52
53
  res.render('pages/player', {
53
54
  title: video.title,
54
55
  video,
55
56
  adjacent,
56
57
  notes,
58
+ queue,
57
59
  });
58
60
  } catch (err) {
59
61
  next(err);
package/src/server.js CHANGED
@@ -27,7 +27,7 @@ const chalk = require('chalk');
27
27
  * @returns {Promise<Object>} Server instance and services
28
28
  */
29
29
  async function startServer(options) {
30
- const { coursePath, port, openBrowser } = options;
30
+ const { coursePath, port, openBrowser, allowFallback } = options;
31
31
 
32
32
  // Initialize database
33
33
  log('Initializing database...');
@@ -53,6 +53,7 @@ async function startServer(options) {
53
53
 
54
54
  // Static files
55
55
  app.use('/static', express.static(path.join(__dirname, '..', 'public')));
56
+ app.use('/lib/plyr', express.static(path.join(__dirname, '..', 'node_modules', 'plyr', 'dist')));
56
57
 
57
58
  // Routes
58
59
  app.use('/', createVideoRoutes({
@@ -92,73 +93,86 @@ async function startServer(options) {
92
93
 
93
94
  // Start server
94
95
  return new Promise((resolve, reject) => {
95
- const server = app.listen(port, () => {
96
- const url = `http://localhost:${port}`;
97
- success(`Server running at ${chalk.cyan(url)}`);
98
-
99
- // Open browser if requested
100
- if (openBrowser) {
101
- log('Opening browser...');
102
- open(url).catch(() => {
103
- // Ignore browser open errors
96
+ let currentPort = port;
97
+
98
+ const start = (retryPort) => {
99
+ const server = app.listen(retryPort, () => {
100
+ const url = `http://localhost:${retryPort}`;
101
+ success(`Server running at ${chalk.cyan(url)}`);
102
+
103
+ // Open browser if requested
104
+ if (openBrowser) {
105
+ log('Opening browser...');
106
+ open(url).catch(() => {
107
+ // Ignore browser open errors
108
+ });
109
+ }
110
+
111
+ // Track active connections
112
+ const sockets = new Set();
113
+ server.on('connection', (socket) => {
114
+ sockets.add(socket);
115
+ server.once('close', () => sockets.delete(socket));
104
116
  });
105
- }
106
117
 
107
- // Track active connections
108
- const sockets = new Set();
109
- server.on('connection', (socket) => {
110
- sockets.add(socket);
111
- server.once('close', () => sockets.delete(socket));
118
+ // Handle graceful shutdown
119
+ let isShuttingDown = false;
120
+ const shutdown = async (signal) => {
121
+ if (isShuttingDown) return;
122
+ isShuttingDown = true;
123
+
124
+ log(`\nShutting down... (${signal})`);
125
+
126
+ // Force exit after timeout if graceful shutdown fails
127
+ const forceExitTimeout = setTimeout(() => {
128
+ error('Forced shutdown after timeout');
129
+ process.exit(1);
130
+ }, 5000);
131
+
132
+ // Destroy all active connections
133
+ for (const socket of sockets) {
134
+ socket.destroy();
135
+ sockets.delete(socket);
136
+ }
137
+
138
+ database.close();
139
+ server.close(() => {
140
+ clearTimeout(forceExitTimeout);
141
+ success('Server closed');
142
+ process.exit(0);
143
+ });
144
+ };
145
+
146
+ process.on('SIGINT', () => shutdown('SIGINT'));
147
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
148
+
149
+ resolve({
150
+ server,
151
+ database,
152
+ services: {
153
+ videoService,
154
+ progressService,
155
+ notesService,
156
+ },
157
+ });
112
158
  });
113
159
 
114
- // Handle graceful shutdown
115
- let isShuttingDown = false;
116
- const shutdown = async (signal) => {
117
- if (isShuttingDown) return;
118
- isShuttingDown = true;
119
-
120
- log(`\nShutting down... (${signal})`);
121
-
122
- // Force exit after timeout if graceful shutdown fails
123
- const forceExitTimeout = setTimeout(() => {
124
- error('Forced shutdown after timeout');
125
- process.exit(1);
126
- }, 5000);
127
-
128
- // Destroy all active connections
129
- for (const socket of sockets) {
130
- socket.destroy();
131
- sockets.delete(socket);
160
+ server.on('error', (err) => {
161
+ if (err.code === 'EADDRINUSE') {
162
+ if (allowFallback) {
163
+ log(`Port ${retryPort} is busy, trying ${retryPort + 1}...`);
164
+ start(retryPort + 1);
165
+ } else {
166
+ error(`Port ${retryPort} is already in use`);
167
+ reject(err);
168
+ }
169
+ } else {
170
+ reject(err);
132
171
  }
133
-
134
- database.close();
135
- server.close(() => {
136
- clearTimeout(forceExitTimeout);
137
- success('Server closed');
138
- process.exit(0);
139
- });
140
- };
141
-
142
- process.on('SIGINT', () => shutdown('SIGINT'));
143
- process.on('SIGTERM', () => shutdown('SIGTERM'));
144
-
145
- resolve({
146
- server,
147
- database,
148
- services: {
149
- videoService,
150
- progressService,
151
- notesService,
152
- },
153
172
  });
154
- });
173
+ };
155
174
 
156
- server.on('error', (err) => {
157
- if (err.code === 'EADDRINUSE') {
158
- error(`Port ${port} is already in use`);
159
- }
160
- reject(err);
161
- });
175
+ start(currentPort);
162
176
  });
163
177
  }
164
178
 
@@ -277,6 +277,38 @@ class VideoService {
277
277
  };
278
278
  }
279
279
 
280
+ /**
281
+ * Get all videos in the same module for queue display
282
+ * @param {number} id - Current video ID
283
+ * @returns {Object} Object with moduleName and videos array
284
+ */
285
+ getQueueVideos(id) {
286
+ const video = this.getVideoById(id);
287
+
288
+ // Get module name
289
+ let moduleName = 'Videos';
290
+ if (video.module_id) {
291
+ const module = this._db.get('SELECT name FROM modules WHERE id = ?', [video.module_id]);
292
+ if (module) {
293
+ moduleName = module.name;
294
+ }
295
+ }
296
+
297
+ // Get all videos in same module, ordered
298
+ const videos = this._db.all(
299
+ `SELECT id, title, status, position, duration FROM videos
300
+ WHERE module_id ${video.module_id ? '= ?' : 'IS NULL'}
301
+ ORDER BY sort_order, filename`,
302
+ video.module_id ? [video.module_id] : []
303
+ );
304
+
305
+ return {
306
+ moduleName,
307
+ videos,
308
+ currentId: id,
309
+ };
310
+ }
311
+
280
312
  /**
281
313
  * Search videos by title
282
314
  * @param {string} query - Search query