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.
- package/README.md +9 -0
- package/package.json +7 -7
- package/public/css/styles.css +521 -81
- package/public/js/player.js +182 -139
- package/{bin/coursewatcher.js → src/cli.js} +8 -6
- package/src/controllers/video-controller.js +2 -0
- package/src/server.js +75 -61
- package/src/services/video-service.js +32 -0
- package/views/pages/player.ejs +135 -92
package/public/js/player.js
CHANGED
|
@@ -1,94 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Video Player Controls
|
|
3
3
|
*
|
|
4
|
-
* Handles
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
132
|
+
player.on('pause', () => saveProgress(player));
|
|
196
133
|
|
|
197
|
-
// Save on video end
|
|
198
|
-
|
|
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
|
-
|
|
205
|
-
const currentPos = Math.floor(
|
|
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 =
|
|
230
|
-
const 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 =
|
|
248
|
-
const 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
|
|
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('
|
|
16
|
-
const { log, success, error } = require('
|
|
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'
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|