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 +2 -2
- package/public/css/styles.css +337 -2
- package/public/js/player.js +128 -3
- package/src/cli.js +4 -2
- package/src/controllers/video-controller.js +2 -0
- package/src/server.js +74 -61
- package/src/services/video-service.js +32 -0
- package/views/pages/player.ejs +135 -76
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coursewatcher",
|
|
3
|
-
"version": "1.
|
|
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
|
+
}
|
package/public/css/styles.css
CHANGED
|
@@ -504,7 +504,7 @@ a:hover {
|
|
|
504
504
|
========================================== */
|
|
505
505
|
|
|
506
506
|
.player-container {
|
|
507
|
-
max-width:
|
|
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
|
+
}
|
package/public/js/player.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Video Player Controls
|
|
3
3
|
*
|
|
4
|
-
* Handles Plyr initialization, keyboard shortcuts,
|
|
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'
|
|
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...');
|
|
@@ -93,73 +93,86 @@ async function startServer(options) {
|
|
|
93
93
|
|
|
94
94
|
// Start server
|
|
95
95
|
return new Promise((resolve, reject) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
package/views/pages/player.ejs
CHANGED
|
@@ -6,90 +6,149 @@
|
|
|
6
6
|
<%- include('../partials/header') %>
|
|
7
7
|
|
|
8
8
|
<main class="container player-container">
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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') %>
|