coursewatcher 1.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursewatcher",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A CLI tool and web interface for tracking progress in downloaded video courses",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -54,4 +54,4 @@
54
54
  "engines": {
55
55
  "node": ">=18.0.0"
56
56
  }
57
- }
57
+ }
@@ -504,7 +504,7 @@ a:hover {
504
504
  ========================================== */
505
505
 
506
506
  .player-container {
507
- max-width: 1400px;
507
+ max-width: 1600px;
508
508
  }
509
509
 
510
510
  .player-section {
@@ -1037,4 +1037,339 @@ a:hover {
1037
1037
  }
1038
1038
 
1039
1039
  /* Hide default controls on fullscreen if needed (we rely on custom ones now) */
1040
- /* .video-player::-webkit-media-controls { display:none !important; } */
1040
+ /* .video-player::-webkit-media-controls { display:none !important; } */
1041
+
1042
+ /* ==========================================
1043
+ Player Layout with Queue
1044
+ ========================================== */
1045
+
1046
+ .player-layout {
1047
+ display: flex;
1048
+ gap: var(--space-md);
1049
+ /* Reduced gap */
1050
+ align-items: flex-start;
1051
+ }
1052
+
1053
+ .player-main {
1054
+ flex: 1;
1055
+ min-width: 0;
1056
+ }
1057
+
1058
+ /* ==========================================
1059
+ Queue Panel
1060
+ ========================================== */
1061
+
1062
+ .queue-panel {
1063
+ width: 30%;
1064
+ /* Slightly narrower width */
1065
+ min-width: 300px;
1066
+ max-width: 450px;
1067
+ flex-shrink: 0;
1068
+ background: var(--color-bg-secondary);
1069
+ border-radius: var(--radius-lg);
1070
+ border: 1px solid var(--color-border);
1071
+ overflow: hidden;
1072
+ position: sticky;
1073
+ top: 100px;
1074
+ max-height: calc(100vh - 120px);
1075
+ display: flex;
1076
+ flex-direction: column;
1077
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
1078
+ }
1079
+
1080
+ .queue-header {
1081
+ padding: var(--space-md) var(--space-lg);
1082
+ border-bottom: 1px solid var(--color-border);
1083
+ background: rgba(255, 255, 255, 0.03);
1084
+ }
1085
+
1086
+ .queue-title {
1087
+ font-size: 1rem;
1088
+ font-weight: 600;
1089
+ margin-bottom: 2px;
1090
+ color: var(--color-text);
1091
+ text-transform: uppercase;
1092
+ letter-spacing: 0.5px;
1093
+ }
1094
+
1095
+ .queue-module-name {
1096
+ font-size: 0.8rem;
1097
+ color: var(--color-text-muted);
1098
+ display: block;
1099
+ white-space: nowrap;
1100
+ overflow: hidden;
1101
+ text-overflow: ellipsis;
1102
+ }
1103
+
1104
+ .queue-list {
1105
+ overflow-y: auto;
1106
+ flex: 1;
1107
+ padding: var(--space-sm);
1108
+ scrollbar-width: thin;
1109
+ scrollbar-color: var(--color-border) transparent;
1110
+ }
1111
+
1112
+ .queue-list::-webkit-scrollbar {
1113
+ width: 6px;
1114
+ }
1115
+
1116
+ .queue-list::-webkit-scrollbar-track {
1117
+ background: transparent;
1118
+ }
1119
+
1120
+ .queue-list::-webkit-scrollbar-thumb {
1121
+ background-color: var(--color-border);
1122
+ border-radius: 3px;
1123
+ }
1124
+
1125
+ .queue-item {
1126
+ display: flex;
1127
+ align-items: center;
1128
+ gap: var(--space-md);
1129
+ padding: var(--space-md);
1130
+ /* More comfortable padding */
1131
+ border-radius: var(--radius-md);
1132
+ color: var(--color-text-secondary);
1133
+ transition: all var(--transition-fast);
1134
+ margin-bottom: 2px;
1135
+ text-decoration: none;
1136
+ border: 1px solid transparent;
1137
+ }
1138
+
1139
+ .queue-item:hover {
1140
+ background: var(--color-bg-tertiary);
1141
+ color: var(--color-text);
1142
+ transform: translateX(2px);
1143
+ }
1144
+
1145
+ .queue-item.active {
1146
+ background: var(--color-surface);
1147
+ color: var(--color-primary);
1148
+ border-color: var(--color-primary);
1149
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1150
+ }
1151
+
1152
+ .queue-item.active:hover {
1153
+ background: var(--color-surface);
1154
+ transform: none;
1155
+ }
1156
+
1157
+ .queue-item-number {
1158
+ flex-shrink: 0;
1159
+ width: 28px;
1160
+ height: 28px;
1161
+ display: flex;
1162
+ align-items: center;
1163
+ justify-content: center;
1164
+ font-size: 0.8rem;
1165
+ font-weight: 600;
1166
+ background: rgba(255, 255, 255, 0.05);
1167
+ border-radius: 50%;
1168
+ /* Circle instead of square */
1169
+ color: var(--color-text-muted);
1170
+ transition: all var(--transition-fast);
1171
+ }
1172
+
1173
+ .queue-item.active .queue-item-number {
1174
+ background: var(--color-primary);
1175
+ color: white;
1176
+ }
1177
+
1178
+ .queue-item-content {
1179
+ flex: 1;
1180
+ min-width: 0;
1181
+ display: flex;
1182
+ flex-direction: column;
1183
+ gap: 2px;
1184
+ }
1185
+
1186
+ .queue-item-title {
1187
+ font-size: 0.9rem;
1188
+ font-weight: 500;
1189
+ display: block;
1190
+ /* Multi-line clamp instead of single line ellipsis for better readability */
1191
+ display: -webkit-box;
1192
+ -webkit-line-clamp: 2;
1193
+ -webkit-box-orient: vertical;
1194
+ overflow: hidden;
1195
+ line-height: 1.4;
1196
+ color: inherit;
1197
+ }
1198
+
1199
+ .queue-item.active .queue-item-title {
1200
+ color: var(--color-text);
1201
+ font-weight: 600;
1202
+ }
1203
+
1204
+ .queue-item-meta {
1205
+ display: flex;
1206
+ align-items: center;
1207
+ gap: var(--space-sm);
1208
+ margin-top: 2px;
1209
+ }
1210
+
1211
+ .queue-item-status {
1212
+ font-size: 0.75rem;
1213
+ display: flex;
1214
+ align-items: center;
1215
+ }
1216
+
1217
+ .queue-item-status.status-completed {
1218
+ color: var(--color-success);
1219
+ }
1220
+
1221
+ .queue-item-status.status-in-progress {
1222
+ color: var(--color-warning);
1223
+ }
1224
+
1225
+ .queue-item-status.status-unwatched {
1226
+ color: var(--color-text-muted);
1227
+ }
1228
+
1229
+ .queue-item.active .queue-item-status {
1230
+ /* color: var(--color-primary); */
1231
+ }
1232
+
1233
+ .queue-item-playing {
1234
+ font-size: 0.65rem;
1235
+ font-weight: 700;
1236
+ text-transform: uppercase;
1237
+ letter-spacing: 0.5px;
1238
+ color: var(--color-primary);
1239
+ background: rgba(124, 58, 237, 0.1);
1240
+ padding: 2px 6px;
1241
+ border-radius: 4px;
1242
+ }
1243
+
1244
+ /* ==========================================
1245
+ Autoplay Countdown Overlay
1246
+ ========================================== */
1247
+
1248
+ .autoplay-overlay {
1249
+ position: absolute;
1250
+ top: 0;
1251
+ left: 0;
1252
+ right: 0;
1253
+ bottom: 0;
1254
+ background: rgba(0, 0, 0, 0.85);
1255
+ display: flex;
1256
+ align-items: center;
1257
+ justify-content: center;
1258
+ z-index: 100;
1259
+ backdrop-filter: blur(8px);
1260
+ }
1261
+
1262
+ .autoplay-overlay.hidden {
1263
+ display: none;
1264
+ }
1265
+
1266
+ .autoplay-content {
1267
+ text-align: center;
1268
+ color: white;
1269
+ }
1270
+
1271
+ .autoplay-countdown-ring {
1272
+ position: relative;
1273
+ width: 120px;
1274
+ height: 120px;
1275
+ margin: 0 auto var(--space-lg);
1276
+ background: none;
1277
+ border: none;
1278
+ padding: 0;
1279
+ cursor: pointer;
1280
+ transition: transform var(--transition-fast);
1281
+ }
1282
+
1283
+ .autoplay-countdown-ring:hover {
1284
+ transform: scale(1.05);
1285
+ }
1286
+
1287
+ .autoplay-countdown-ring:hover .countdown-progress {
1288
+ stroke: var(--color-primary-hover);
1289
+ }
1290
+
1291
+ .autoplay-countdown-ring svg {
1292
+ width: 100%;
1293
+ height: 100%;
1294
+ transform: rotate(-90deg);
1295
+ }
1296
+
1297
+ .countdown-bg {
1298
+ fill: none;
1299
+ stroke: rgba(255, 255, 255, 0.2);
1300
+ stroke-width: 6;
1301
+ }
1302
+
1303
+ .countdown-progress {
1304
+ fill: none;
1305
+ stroke: var(--color-primary);
1306
+ stroke-width: 6;
1307
+ stroke-linecap: round;
1308
+ stroke-dasharray: 283;
1309
+ stroke-dashoffset: 0;
1310
+ transition: stroke-dashoffset 1s linear;
1311
+ }
1312
+
1313
+ .countdown-number {
1314
+ position: absolute;
1315
+ top: 50%;
1316
+ left: 50%;
1317
+ transform: translate(-50%, -50%);
1318
+ font-size: 2.5rem;
1319
+ font-weight: 700;
1320
+ color: white;
1321
+ text-shadow: 0 0 20px rgba(124, 58, 237, 0.8), 0 2px 4px rgba(0, 0, 0, 0.5);
1322
+ }
1323
+
1324
+ .autoplay-text {
1325
+ font-size: 0.9rem;
1326
+ color: var(--color-text-secondary);
1327
+ margin-bottom: var(--space-xs);
1328
+ text-transform: uppercase;
1329
+ letter-spacing: 1px;
1330
+ }
1331
+
1332
+ .autoplay-next-title {
1333
+ font-size: 1.25rem;
1334
+ font-weight: 600;
1335
+ margin-bottom: var(--space-lg);
1336
+ max-width: 400px;
1337
+ overflow: hidden;
1338
+ text-overflow: ellipsis;
1339
+ white-space: nowrap;
1340
+ }
1341
+
1342
+ .autoplay-cancel {
1343
+ background: rgba(255, 255, 255, 0.1);
1344
+ border: 1px solid rgba(255, 255, 255, 0.3);
1345
+ color: white;
1346
+ padding: var(--space-sm) var(--space-xl);
1347
+ font-size: 0.9rem;
1348
+ }
1349
+
1350
+ .autoplay-cancel:hover {
1351
+ background: rgba(255, 255, 255, 0.2);
1352
+ border-color: rgba(255, 255, 255, 0.5);
1353
+ transform: none;
1354
+ box-shadow: none;
1355
+ }
1356
+
1357
+ /* Responsive: Queue below player on smaller screens */
1358
+ @media (max-width: 1024px) {
1359
+ .player-layout {
1360
+ flex-direction: column;
1361
+ }
1362
+
1363
+ .queue-panel {
1364
+ width: 100%;
1365
+ position: static;
1366
+ max-height: 400px;
1367
+ }
1368
+ }
1369
+
1370
+ /* Fullscreen autoplay overlay */
1371
+ :fullscreen .autoplay-overlay,
1372
+ :-webkit-full-screen .autoplay-overlay,
1373
+ :-moz-full-screen .autoplay-overlay {
1374
+ position: fixed;
1375
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Video Player Controls
3
3
  *
4
- * Handles Plyr initialization, keyboard shortcuts, 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 () {
@@ -9,6 +10,7 @@
9
10
 
10
11
  // Constants
11
12
  const SAVE_INTERVAL = 5000; // Save progress every 5 seconds
13
+ const AUTOPLAY_COUNTDOWN = 5; // Seconds before auto-navigating to next video
12
14
 
13
15
  // Elements
14
16
  const videoElement = document.getElementById('videoPlayer');
@@ -19,13 +21,23 @@
19
21
  const notesSaveStatus = document.getElementById('notesSaveStatus');
20
22
  const statusButtons = document.querySelectorAll('.status-btn');
21
23
 
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');
30
+
22
31
  if (!videoElement) return;
23
32
 
24
33
  const videoId = videoElement.dataset.videoId;
25
34
  const savedPosition = parseFloat(videoElement.dataset.savedPosition) || 0;
35
+ const nextVideoUrl = videoElement.dataset.nextVideoUrl;
26
36
 
27
37
  let saveTimeout = null;
28
38
  let lastSavedPosition = savedPosition;
39
+ let autoplayInterval = null;
40
+ let autoplayCountdown = AUTOPLAY_COUNTDOWN;
29
41
 
30
42
  // ==========================================
31
43
  // Initialization
@@ -55,17 +67,24 @@
55
67
  ]
56
68
  });
57
69
 
58
- // Restore saved position
70
+ // Restore saved position and autoplay
59
71
  player.on('ready', () => {
60
72
  if (savedPosition > 0) {
61
73
  player.currentTime = savedPosition;
62
74
  }
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
+ });
63
81
  });
64
82
 
65
83
  // Setup Logic
66
84
  setupStatusControls();
67
85
  setupNotesControls();
68
86
  setupProgressAutoSave(player);
87
+ setupAutoplay(player);
69
88
 
70
89
  // Custom shortcuts not covered by Plyr (if any)
71
90
  // Plyr covers Space, K, F, M, Arrow keys.
@@ -112,7 +131,7 @@
112
131
  // Save on pause
113
132
  player.on('pause', () => saveProgress(player));
114
133
 
115
- // Save on video end
134
+ // Save on video end (but don't update status here, autoplay handles it)
116
135
  player.on('ended', () => {
117
136
  saveProgress(player);
118
137
  updateStatus('completed');
@@ -174,6 +193,111 @@
174
193
  );
175
194
  }
176
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
+
177
301
  // ==========================================
178
302
  // Notes Controls
179
303
  // ==========================================
@@ -232,3 +356,4 @@
232
356
  init();
233
357
  }
234
358
  })();
359
+
package/src/cli.js CHANGED
@@ -20,18 +20,20 @@ program
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...');
@@ -93,73 +93,86 @@ async function startServer(options) {
93
93
 
94
94
  // Start server
95
95
  return new Promise((resolve, reject) => {
96
- const server = app.listen(port, () => {
97
- const url = `http://localhost:${port}`;
98
- success(`Server running at ${chalk.cyan(url)}`);
99
-
100
- // Open browser if requested
101
- if (openBrowser) {
102
- log('Opening browser...');
103
- open(url).catch(() => {
104
- // 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));
105
116
  });
106
- }
107
117
 
108
- // Track active connections
109
- const sockets = new Set();
110
- server.on('connection', (socket) => {
111
- sockets.add(socket);
112
- 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
+ });
113
158
  });
114
159
 
115
- // Handle graceful shutdown
116
- let isShuttingDown = false;
117
- const shutdown = async (signal) => {
118
- if (isShuttingDown) return;
119
- isShuttingDown = true;
120
-
121
- log(`\nShutting down... (${signal})`);
122
-
123
- // Force exit after timeout if graceful shutdown fails
124
- const forceExitTimeout = setTimeout(() => {
125
- error('Forced shutdown after timeout');
126
- process.exit(1);
127
- }, 5000);
128
-
129
- // Destroy all active connections
130
- for (const socket of sockets) {
131
- socket.destroy();
132
- 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);
133
171
  }
134
-
135
- database.close();
136
- server.close(() => {
137
- clearTimeout(forceExitTimeout);
138
- success('Server closed');
139
- process.exit(0);
140
- });
141
- };
142
-
143
- process.on('SIGINT', () => shutdown('SIGINT'));
144
- process.on('SIGTERM', () => shutdown('SIGTERM'));
145
-
146
- resolve({
147
- server,
148
- database,
149
- services: {
150
- videoService,
151
- progressService,
152
- notesService,
153
- },
154
172
  });
155
- });
173
+ };
156
174
 
157
- server.on('error', (err) => {
158
- if (err.code === 'EADDRINUSE') {
159
- error(`Port ${port} is already in use`);
160
- }
161
- reject(err);
162
- });
175
+ start(currentPort);
163
176
  });
164
177
  }
165
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
@@ -6,90 +6,149 @@
6
6
  <%- include('../partials/header') %>
7
7
 
8
8
  <main class="container player-container">
9
- <!-- Video Player Section -->
10
- <!-- Video Player Section -->
11
- <section class="player-section">
12
- <h1 class="video-title">
13
- <%= video.title %>
14
- </h1>
15
- <div class="video-wrapper">
16
- <video id="videoPlayer" class="plyr" playsinline controls data-video-id="<%= video.id %>"
17
- data-saved-position="<%= video.position %>">
18
- <source src="/api/videos/<%= video.id %>/stream" type="video/mp4">
19
- Your browser does not support the video tag.
20
- </video>
21
- </div>
9
+ <div class="player-layout">
10
+ <!-- Main Player Section -->
11
+ <div class="player-main">
12
+ <!-- Video Player Section -->
13
+ <section class="player-section">
14
+ <h1 class="video-title">
15
+ <%= video.title %>
16
+ </h1>
17
+ <div class="video-wrapper">
18
+ <video id="videoPlayer" class="plyr" playsinline controls
19
+ data-video-id="<%= video.id %>" data-saved-position="<%= video.position %>"
20
+ data-next-video-url="<%= adjacent.next ? '/video/' + adjacent.next : '' %>">
21
+ <source src="/api/videos/<%= video.id %>/stream" type="video/mp4">
22
+ Your browser does not support the video tag.
23
+ </video>
22
24
 
23
- <!-- Navigation -->
24
- <div class="player-nav">
25
- <% if (adjacent.prev) { %>
26
- <a href="/video/<%= adjacent.prev %>" class="nav-button prev">
27
- Previous
28
- </a>
29
- <% } else { %>
30
- <span class="nav-button disabled">← Previous</span>
31
- <% } %>
25
+ <!-- Auto-play Countdown Overlay -->
26
+ <div id="autoplayOverlay" class="autoplay-overlay hidden">
27
+ <div class="autoplay-content">
28
+ <button id="skipToNext" class="autoplay-countdown-ring"
29
+ title="Click to play now">
30
+ <svg viewBox="0 0 100 100">
31
+ <circle cx="50" cy="50" r="45" class="countdown-bg" />
32
+ <circle cx="50" cy="50" r="45" class="countdown-progress"
33
+ id="countdownProgress" />
34
+ </svg>
35
+ <span id="countdownNumber" class="countdown-number">5</span>
36
+ </button>
37
+ <p class="autoplay-text">Up Next</p>
38
+ <p id="nextVideoTitle" class="autoplay-next-title"></p>
39
+ <button id="cancelAutoplay" class="btn autoplay-cancel">Cancel</button>
40
+ </div>
41
+ </div>
42
+ </div>
32
43
 
33
- <a href="/" class="nav-button home">📚 Back to Course</a>
44
+ <!-- Navigation -->
45
+ <div class="player-nav">
46
+ <% if (adjacent.prev) { %>
47
+ <a href="/video/<%= adjacent.prev %>" class="nav-button prev">
48
+ ← Previous
49
+ </a>
50
+ <% } else { %>
51
+ <span class="nav-button disabled">← Previous</span>
52
+ <% } %>
34
53
 
35
- <% if (adjacent.next) { %>
36
- <a href="/video/<%= adjacent.next %>" class="nav-button next">
37
- Next →
38
- </a>
39
- <% } else { %>
40
- <span class="nav-button disabled">Next →</span>
41
- <% } %>
42
- </div>
43
- </section>
54
+ <a href="/" class="nav-button home">📚 Back to Course</a>
44
55
 
45
- <!-- Video Info Section -->
46
- <section class="video-info-section">
56
+ <% if (adjacent.next) { %>
57
+ <a href="/video/<%= adjacent.next %>" class="nav-button next">
58
+ Next →
59
+ </a>
60
+ <% } else { %>
61
+ <span class="nav-button disabled">Next →</span>
62
+ <% } %>
63
+ </div>
64
+ </section>
47
65
 
48
- <!-- Status Controls -->
49
- <div class="status-controls">
50
- <span class="status-label">Status:</span>
51
- <div class="status-buttons">
52
- <button class="status-btn <%= video.status === 'unwatched' ? 'active' : '' %>"
53
- data-status="unwatched">
54
- ○ Unwatched
55
- </button>
56
- <button class="status-btn <%= video.status === 'in-progress' ? 'active' : '' %>"
57
- data-status="in-progress">
58
- ▶ In Progress
59
- </button>
60
- <button class="status-btn <%= video.status === 'completed' ? 'active' : '' %>"
61
- data-status="completed">
62
- ✓ Completed
63
- </button>
64
- </div>
65
- </div>
66
+ <!-- Video Info Section -->
67
+ <section class="video-info-section">
66
68
 
67
- <!-- Keyboard Shortcuts Help -->
68
- <details class="shortcuts-help">
69
- <summary>⌨️ Keyboard Shortcuts</summary>
70
- <div class="shortcuts-grid">
71
- <div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div>
72
- <div class="shortcut"><kbd>M</kbd> Mute/Unmute</div>
73
- <div class="shortcut"><kbd>F</kbd> Fullscreen</div>
74
- <div class="shortcut"><kbd>←</kbd> Back 10s</div>
75
- <div class="shortcut"><kbd>→</kbd> Forward 10s</div>
76
- <div class="shortcut"><kbd>↑</kbd> Volume Up</div>
77
- <div class="shortcut"><kbd>↓</kbd> Volume Down</div>
78
- <div class="shortcut"><kbd>C</kbd> Captions</div>
79
- </div>
80
- </details>
81
- </section>
69
+ <!-- Status Controls -->
70
+ <div class="status-controls">
71
+ <span class="status-label">Status:</span>
72
+ <div class="status-buttons">
73
+ <button class="status-btn <%= video.status === 'unwatched' ? 'active' : '' %>"
74
+ data-status="unwatched">
75
+ Unwatched
76
+ </button>
77
+ <button class="status-btn <%= video.status === 'in-progress' ? 'active' : '' %>"
78
+ data-status="in-progress">
79
+ In Progress
80
+ </button>
81
+ <button class="status-btn <%= video.status === 'completed' ? 'active' : '' %>"
82
+ data-status="completed">
83
+ ✓ Completed
84
+ </button>
85
+ </div>
86
+ </div>
82
87
 
83
- <!-- Notes Section -->
84
- <section class="notes-section">
85
- <h2 class="section-title">📝 Notes</h2>
86
- <textarea id="notesEditor" class="notes-editor"
87
- placeholder="Take notes for this video... (Markdown supported)"><%= notes.content || '' %></textarea>
88
- <div class="notes-actions">
89
- <button id="saveNotes" class="btn btn-primary">Save Notes</button>
90
- <span id="notesSaveStatus" class="save-status"></span>
88
+ <!-- Keyboard Shortcuts Help -->
89
+ <details class="shortcuts-help">
90
+ <summary>⌨️ Keyboard Shortcuts</summary>
91
+ <div class="shortcuts-grid">
92
+ <div class="shortcut"><kbd>Space</kbd> / <kbd>K</kbd> Play/Pause</div>
93
+ <div class="shortcut"><kbd>M</kbd> Mute/Unmute</div>
94
+ <div class="shortcut"><kbd>F</kbd> Fullscreen</div>
95
+ <div class="shortcut"><kbd>←</kbd> Back 10s</div>
96
+ <div class="shortcut"><kbd>→</kbd> Forward 10s</div>
97
+ <div class="shortcut"><kbd>↑</kbd> Volume Up</div>
98
+ <div class="shortcut"><kbd>↓</kbd> Volume Down</div>
99
+ <div class="shortcut"><kbd>C</kbd> Captions</div>
100
+ </div>
101
+ </details>
102
+ </section>
103
+
104
+ <!-- Notes Section -->
105
+ <section class="notes-section">
106
+ <h2 class="section-title">📝 Notes</h2>
107
+ <textarea id="notesEditor" class="notes-editor"
108
+ placeholder="Take notes for this video... (Markdown supported)"><%= notes.content || '' %></textarea>
109
+ <div class="notes-actions">
110
+ <button id="saveNotes" class="btn btn-primary">Save Notes</button>
111
+ <span id="notesSaveStatus" class="save-status"></span>
112
+ </div>
113
+ </section>
91
114
  </div>
92
- </section>
115
+
116
+ <!-- Queue Panel -->
117
+ <aside class="queue-panel">
118
+ <div class="queue-header">
119
+ <h2 class="queue-title">📋 Queue</h2>
120
+ <span class="queue-module-name">
121
+ <%= queue.moduleName %>
122
+ </span>
123
+ </div>
124
+ <div class="queue-list">
125
+ <% queue.videos.forEach((qVideo, index)=> { %>
126
+ <a href="/video/<%= qVideo.id %>"
127
+ class="queue-item <%= qVideo.id === queue.currentId ? 'active' : '' %>"
128
+ data-video-id="<%= qVideo.id %>" data-video-title="<%= qVideo.title %>">
129
+ <span class="queue-item-number">
130
+ <%= index + 1 %>
131
+ </span>
132
+ <div class="queue-item-content">
133
+ <span class="queue-item-title">
134
+ <%= qVideo.title %>
135
+ </span>
136
+ <div class="queue-item-meta">
137
+ <span class="queue-item-status status-<%= qVideo.status %>">
138
+ <% if (qVideo.status==='completed' ) { %>✓
139
+ <% } else if (qVideo.status==='in-progress' ) { %>▶
140
+ <% } else { %>○<% } %>
141
+ </span>
142
+ <% if (qVideo.id===queue.currentId) { %>
143
+ <span class="queue-item-playing">Now Playing</span>
144
+ <% } %>
145
+ </div>
146
+ </div>
147
+ </a>
148
+ <% }); %>
149
+ </div>
150
+ </aside>
151
+ </div>
93
152
  </main>
94
153
 
95
154
  <%- include('../partials/footer') %>