coursewatcher 1.3.0 → 1.3.1
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 +1 -1
- package/public/js/player.js +19 -19
- package/src/controllers/video-controller.js +31 -17
- package/src/services/progress-service.js +56 -35
- package/src/utils/config.js +7 -6
- package/views/pages/player.ejs +9 -9
package/package.json
CHANGED
package/public/js/player.js
CHANGED
|
@@ -27,17 +27,17 @@
|
|
|
27
27
|
const countdownProgress = document.getElementById('countdownProgress');
|
|
28
28
|
const nextVideoTitle = document.getElementById('nextVideoTitle');
|
|
29
29
|
const cancelAutoplayBtn = document.getElementById('cancelAutoplay');
|
|
30
|
-
|
|
31
|
-
if (!videoElement) return;
|
|
32
|
-
|
|
33
|
-
const videoId = videoElement.dataset.videoId;
|
|
34
|
-
const
|
|
35
|
-
const nextVideoUrl = videoElement.dataset.nextVideoUrl;
|
|
36
|
-
|
|
37
|
-
let saveTimeout = null;
|
|
38
|
-
let lastSavedPosition =
|
|
39
|
-
let autoplayInterval = null;
|
|
40
|
-
let autoplayCountdown = AUTOPLAY_COUNTDOWN;
|
|
30
|
+
|
|
31
|
+
if (!videoElement) return;
|
|
32
|
+
|
|
33
|
+
const videoId = videoElement.dataset.videoId;
|
|
34
|
+
const startPosition = parseFloat(videoElement.dataset.startPosition) || 0;
|
|
35
|
+
const nextVideoUrl = videoElement.dataset.nextVideoUrl;
|
|
36
|
+
|
|
37
|
+
let saveTimeout = null;
|
|
38
|
+
let lastSavedPosition = startPosition;
|
|
39
|
+
let autoplayInterval = null;
|
|
40
|
+
let autoplayCountdown = AUTOPLAY_COUNTDOWN;
|
|
41
41
|
|
|
42
42
|
// ==========================================
|
|
43
43
|
// Initialization
|
|
@@ -67,14 +67,14 @@
|
|
|
67
67
|
]
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
// Restore saved position and autoplay
|
|
71
|
-
player.on('ready', () => {
|
|
72
|
-
if (
|
|
73
|
-
player.currentTime =
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Always try to autoplay
|
|
77
|
-
player.play().catch(() => {
|
|
70
|
+
// Restore saved position and autoplay
|
|
71
|
+
player.on('ready', () => {
|
|
72
|
+
if (startPosition > 0) {
|
|
73
|
+
player.currentTime = startPosition;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Always try to autoplay
|
|
77
|
+
player.play().catch(() => {
|
|
78
78
|
// Autoplay might be blocked by browser on first visit, ignore
|
|
79
79
|
console.log('Autoplay was prevented by browser');
|
|
80
80
|
});
|
|
@@ -6,9 +6,21 @@
|
|
|
6
6
|
* @module controllers/video-controller
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const path = require('path');
|
|
10
|
-
const express = require('express');
|
|
11
|
-
const {
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const express = require('express');
|
|
11
|
+
const { config } = require('../utils/config');
|
|
12
|
+
const { NotFoundError } = require('../utils/errors');
|
|
13
|
+
|
|
14
|
+
function getPlaybackStartPosition(video) {
|
|
15
|
+
const isShortVideo = video.duration > 0
|
|
16
|
+
&& video.duration < config.shortVideoResumeCutoffSeconds;
|
|
17
|
+
|
|
18
|
+
if (video.status === 'completed' || isShortVideo) {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return video.status === 'in-progress' ? video.position : 0;
|
|
23
|
+
}
|
|
12
24
|
|
|
13
25
|
/**
|
|
14
26
|
* Create video routes
|
|
@@ -46,20 +58,22 @@ function createVideoRoutes(services) {
|
|
|
46
58
|
try {
|
|
47
59
|
const videoId = parseInt(req.params.id, 10);
|
|
48
60
|
const video = videoService.getVideoById(videoId);
|
|
49
|
-
const adjacent = videoService.getAdjacentVideos(videoId);
|
|
50
|
-
const notes = notesService.getNotes(videoId);
|
|
51
|
-
const queue = videoService.getQueueVideos(videoId);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
video,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
61
|
+
const adjacent = videoService.getAdjacentVideos(videoId);
|
|
62
|
+
const notes = notesService.getNotes(videoId);
|
|
63
|
+
const queue = videoService.getQueueVideos(videoId);
|
|
64
|
+
const startPosition = getPlaybackStartPosition(video);
|
|
65
|
+
|
|
66
|
+
res.render('pages/player', {
|
|
67
|
+
title: video.title,
|
|
68
|
+
video,
|
|
69
|
+
adjacent,
|
|
70
|
+
notes,
|
|
71
|
+
queue,
|
|
72
|
+
startPosition,
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
next(err);
|
|
76
|
+
}
|
|
63
77
|
});
|
|
64
78
|
|
|
65
79
|
/**
|
|
@@ -28,47 +28,68 @@ class ProgressService {
|
|
|
28
28
|
* @param {number} [duration] - Video duration in seconds
|
|
29
29
|
* @returns {Object} Updated video
|
|
30
30
|
*/
|
|
31
|
-
updatePosition(videoId, position, duration = null) {
|
|
32
|
-
// Validate inputs
|
|
33
|
-
if (typeof position !== 'number' || position < 0) {
|
|
34
|
-
throw new ValidationError('Position must be a non-negative number');
|
|
35
|
-
}
|
|
31
|
+
updatePosition(videoId, position, duration = null) {
|
|
32
|
+
// Validate inputs
|
|
33
|
+
if (typeof position !== 'number' || position < 0) {
|
|
34
|
+
throw new ValidationError('Position must be a non-negative number');
|
|
35
|
+
}
|
|
36
36
|
|
|
37
37
|
// Get current video
|
|
38
38
|
const video = this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
|
|
39
39
|
if (!video) {
|
|
40
40
|
throw new NotFoundError(`Video with id ${videoId}`);
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
// Determine new status based on position
|
|
44
|
-
let newStatus = video.status;
|
|
45
|
-
const videoDuration = duration
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
42
|
+
|
|
43
|
+
// Determine new status based on position
|
|
44
|
+
let newStatus = video.status;
|
|
45
|
+
const videoDuration = typeof duration === 'number' && !Number.isNaN(duration)
|
|
46
|
+
? duration
|
|
47
|
+
: video.duration;
|
|
48
|
+
const isShortVideo = videoDuration > 0
|
|
49
|
+
&& videoDuration < config.shortVideoResumeCutoffSeconds;
|
|
50
|
+
const completionPosition = videoDuration * config.completionThreshold;
|
|
51
|
+
|
|
52
|
+
if (videoDuration > 0) {
|
|
53
|
+
const watchPercent = position / videoDuration;
|
|
54
|
+
|
|
55
|
+
if (watchPercent >= config.completionThreshold) {
|
|
56
|
+
newStatus = 'completed';
|
|
57
|
+
} else if (position > 0) {
|
|
58
|
+
newStatus = 'in-progress';
|
|
59
|
+
}
|
|
60
|
+
} else if (position > 0 && video.status === 'unwatched') {
|
|
61
|
+
newStatus = 'in-progress';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const shouldPreserveCompletedProgress = video.status === 'completed'
|
|
65
|
+
&& videoDuration > 0
|
|
66
|
+
&& position < completionPosition;
|
|
67
|
+
|
|
68
|
+
if (shouldPreserveCompletedProgress) {
|
|
69
|
+
newStatus = 'completed';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let newPosition = position;
|
|
73
|
+
|
|
74
|
+
if (shouldPreserveCompletedProgress) {
|
|
75
|
+
newPosition = video.position;
|
|
76
|
+
} else if (isShortVideo && newStatus !== 'completed') {
|
|
77
|
+
newPosition = 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Update database
|
|
81
|
+
this._db.run(
|
|
82
|
+
`UPDATE videos
|
|
83
|
+
SET position = ?,
|
|
84
|
+
duration = COALESCE(?, duration),
|
|
85
|
+
status = ?,
|
|
86
|
+
updated_at = CURRENT_TIMESTAMP
|
|
87
|
+
WHERE id = ?`,
|
|
88
|
+
[newPosition, duration, newStatus, videoId]
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return this._db.get('SELECT * FROM videos WHERE id = ?', [videoId]);
|
|
92
|
+
}
|
|
72
93
|
|
|
73
94
|
/**
|
|
74
95
|
* Update video status manually
|
package/src/utils/config.js
CHANGED
|
@@ -16,12 +16,13 @@ const config = {
|
|
|
16
16
|
dbFolder: '.coursewatcher',
|
|
17
17
|
dbFilename: 'database.sqlite',
|
|
18
18
|
|
|
19
|
-
// Video settings
|
|
20
|
-
videoExtensions: ['.mp4', '.webm', '.ogv', '.ogg'],
|
|
21
|
-
completionThreshold: 0.9, // 90% watched = completed
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
// Video settings
|
|
20
|
+
videoExtensions: ['.mp4', '.webm', '.ogv', '.ogg'],
|
|
21
|
+
completionThreshold: 0.9, // 90% watched = completed
|
|
22
|
+
shortVideoResumeCutoffSeconds: 300,
|
|
23
|
+
|
|
24
|
+
// Playback settings
|
|
25
|
+
defaultPlaybackSpeed: 1.0,
|
|
25
26
|
speedStep: 0.25,
|
|
26
27
|
seekShort: 5,
|
|
27
28
|
seekMedium: 10,
|
package/views/pages/player.ejs
CHANGED
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
<section class="player-section">
|
|
14
14
|
<h1 class="video-title">
|
|
15
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-
|
|
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>
|
|
16
|
+
</h1>
|
|
17
|
+
<div class="video-wrapper">
|
|
18
|
+
<video id="videoPlayer" class="plyr" playsinline controls
|
|
19
|
+
data-video-id="<%= video.id %>" data-start-position="<%= startPosition %>"
|
|
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>
|
|
24
24
|
|
|
25
25
|
<!-- Auto-play Countdown Overlay -->
|
|
26
26
|
<div id="autoplayOverlay" class="autoplay-overlay hidden">
|
|
@@ -158,4 +158,4 @@
|
|
|
158
158
|
<script src="/static/js/player.js"></script>
|
|
159
159
|
</body>
|
|
160
160
|
|
|
161
|
-
</html>
|
|
161
|
+
</html>
|