vidply 1.2.0 → 1.2.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/README.md +946 -946
- package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
- package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
- package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
- package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
- package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
- package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
- package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
- package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
- package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
- package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
- package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
- package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
- package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
- package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
- package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
- package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
- package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
- package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
- package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
- package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
- package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
- package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
- package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
- package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
- package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
- package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
- package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
- package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
- package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
- package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
- package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
- package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
- package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
- package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
- package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
- package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
- package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
- package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
- package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
- package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
- package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
- package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
- package/dist/dev/vidply.esm.js +73 -34
- package/dist/dev/vidply.esm.js.map +2 -2
- package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
- package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
- package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
- package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
- package/dist/legacy/vidply.js +345 -32
- package/dist/legacy/vidply.js.map +3 -3
- package/dist/legacy/vidply.min.js +2 -2
- package/dist/legacy/vidply.min.meta.json +66 -47
- package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
- package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
- package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
- package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
- package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
- package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
- package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
- package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
- package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
- package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
- package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
- package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
- package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
- package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
- package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
- package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
- package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
- package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
- package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
- package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
- package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
- package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
- package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
- package/dist/prod/vidply.esm.min.js +2 -2
- package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
- package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
- package/dist/vidply.css +5796 -5796
- package/dist/vidply.esm.min.meta.json +148 -135
- package/package.json +7 -7
- package/src/controls/CaptionManager.ts +598 -598
- package/src/controls/ControlBar.ts +4159 -4159
- package/src/controls/KeyboardManager.ts +383 -383
- package/src/controls/SettingsDialog.ts +488 -488
- package/src/controls/TranscriptManager.ts +2426 -2426
- package/src/core/AudioDescriptionManager.ts +863 -774
- package/src/core/DescriptionSpeechManager.ts +232 -0
- package/src/core/FloatingPlayerManager.ts +674 -674
- package/src/core/LazyInit.ts +80 -80
- package/src/core/MetadataAlertsManager.ts +514 -514
- package/src/core/Player.ts +2915 -2872
- package/src/core/PosterManager.ts +169 -169
- package/src/core/PseudoFullscreen.ts +179 -179
- package/src/core/ResponsiveManager.ts +231 -231
- package/src/core/ResumeManager.ts +236 -236
- package/src/core/SignLanguageManager.ts +1308 -1308
- package/src/core/ThemeManager.ts +157 -157
- package/src/features/PlaylistManager.ts +1838 -1827
- package/src/i18n/i18n.ts +223 -223
- package/src/i18n/languages/de.ts +217 -217
- package/src/i18n/languages/en.ts +218 -218
- package/src/i18n/languages/es.ts +214 -214
- package/src/i18n/languages/fr.ts +214 -214
- package/src/i18n/languages/ja.ts +214 -214
- package/src/icons/Icons.ts +167 -167
- package/src/index.ts +196 -193
- package/src/renderers/DASHRenderer.ts +1077 -1077
- package/src/renderers/HLSRenderer.ts +944 -944
- package/src/renderers/HTML5Renderer.ts +389 -389
- package/src/renderers/SoundCloudRenderer.ts +437 -437
- package/src/renderers/VimeoRenderer.ts +298 -298
- package/src/renderers/YouTubeRenderer.ts +290 -290
- package/src/styles/vidply.css +5796 -5796
- package/src/types/events.ts +124 -122
- package/src/types/globals.d.ts +313 -313
- package/src/types/options.ts +185 -182
- package/src/types/renderer.ts +58 -58
- package/src/utils/DOMUtils.ts +281 -281
- package/src/utils/DownloadInfo.ts +171 -171
- package/src/utils/DraggablePanel.ts +539 -539
- package/src/utils/DraggablePanelMenu.ts +195 -195
- package/src/utils/DraggableResizable.ts +997 -997
- package/src/utils/EventEmitter.ts +59 -59
- package/src/utils/FormUtils.ts +95 -95
- package/src/utils/MenuFactory.ts +483 -483
- package/src/utils/Sanitize.ts +70 -70
- package/src/utils/SettingsMenuFactory.ts +289 -289
- package/src/utils/StorageManager.ts +287 -287
- package/src/utils/TrackLabelUtils.ts +52 -52
- package/src/utils/UrlSafe.ts +69 -69
- package/src/utils/VideoFrameCapture.ts +114 -114
- package/src/utils/WindowComponents.ts +250 -250
- package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
- package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
- package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
- package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
- package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
- package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
- package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
- package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
- package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
- package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
- package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
- package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
- package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
- package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
- package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
- package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
- package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
- package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
- package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
- package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
- package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
- package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
- package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
- /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
- /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-YVCYQZJI.js.map} +0 -0
|
@@ -1,236 +1,236 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resume-playback feature extracted from Player.
|
|
3
|
-
*
|
|
4
|
-
* Owns:
|
|
5
|
-
* - Throttled progress persistence during playback.
|
|
6
|
-
* - "Where were we?" detection on `loadedmetadata`.
|
|
7
|
-
* - Clearing persisted progress on `ended`.
|
|
8
|
-
* - The optional resume prompt (accessible modal with two buttons).
|
|
9
|
-
*
|
|
10
|
-
* Because `resumePlayback` is opt-in, the Player only instantiates
|
|
11
|
-
* this manager when the option is enabled — so pages without the
|
|
12
|
-
* feature don't pay for the extra state / event handlers.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { DOMUtils } from '../utils/DOMUtils.js';
|
|
16
|
-
import { i18n } from '../i18n/i18n.js';
|
|
17
|
-
import { throttle } from '../utils/PerformanceUtils.js';
|
|
18
|
-
import type { Player } from './Player.js';
|
|
19
|
-
|
|
20
|
-
export class ResumeManager {
|
|
21
|
-
private readonly player: Player;
|
|
22
|
-
private saveProgressThrottled: (() => void) | null = null;
|
|
23
|
-
private resumeChecked = false;
|
|
24
|
-
private listenersAttached = false;
|
|
25
|
-
|
|
26
|
-
constructor(player: Player) {
|
|
27
|
-
this.player = player;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Wire up the progress-save + resume-check listeners. Safe to call
|
|
32
|
-
* multiple times: repeat calls are no-ops so a re-init path during
|
|
33
|
-
* source switching doesn't stack duplicate listeners.
|
|
34
|
-
*/
|
|
35
|
-
init(): void {
|
|
36
|
-
if (this.listenersAttached) return;
|
|
37
|
-
this.listenersAttached = true;
|
|
38
|
-
|
|
39
|
-
this.saveProgressThrottled = throttle(() => this.saveProgress(), 5000);
|
|
40
|
-
|
|
41
|
-
this.player.on('timeupdate', () => {
|
|
42
|
-
if (this.player.state.playing && this.player.state.duration > 0) {
|
|
43
|
-
this.saveProgressThrottled?.();
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// First `loadedmetadata` after init is the only place we should
|
|
48
|
-
// offer to resume — after that the user is in charge of seeking.
|
|
49
|
-
this.player.on('loadedmetadata', () => {
|
|
50
|
-
if (!this.resumeChecked) {
|
|
51
|
-
this.resumeChecked = true;
|
|
52
|
-
this.checkForResume();
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
this.player.on('ended', () => {
|
|
57
|
-
const videoId = this.player.getVideoId();
|
|
58
|
-
if (videoId) {
|
|
59
|
-
this.player.storage.clearWatchProgress(videoId);
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Persist current playback progress to storage. No-op when the
|
|
66
|
-
* feature is disabled, when the video is too short / at the very
|
|
67
|
-
* start, or when playback is effectively complete.
|
|
68
|
-
*/
|
|
69
|
-
saveProgress(): void {
|
|
70
|
-
const player = this.player;
|
|
71
|
-
if (!player.options.resumePlayback) return;
|
|
72
|
-
|
|
73
|
-
const videoId = player.getVideoId();
|
|
74
|
-
if (!videoId) return;
|
|
75
|
-
|
|
76
|
-
const currentTime = player.state.currentTime;
|
|
77
|
-
const duration = player.state.duration;
|
|
78
|
-
|
|
79
|
-
// Resume for tiny clips is more annoying than useful.
|
|
80
|
-
if (duration < 30 || currentTime < (player.options.resumeThreshold as number)) {
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Near the end? Don't persist — the user effectively finished.
|
|
85
|
-
const percentage = (currentTime / duration) * 100;
|
|
86
|
-
if (percentage > 95) return;
|
|
87
|
-
|
|
88
|
-
player.storage.saveWatchProgress(videoId, currentTime, duration);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Check for a previously-saved resume point for the current video
|
|
93
|
-
* and either auto-resume or show the prompt depending on
|
|
94
|
-
* `options.resumePrompt`. Safe to call manually, e.g. after an
|
|
95
|
-
* external source change.
|
|
96
|
-
*/
|
|
97
|
-
checkForResume(): void {
|
|
98
|
-
const player = this.player;
|
|
99
|
-
if (!player.options.resumePlayback) return;
|
|
100
|
-
|
|
101
|
-
const videoId = player.getVideoId();
|
|
102
|
-
if (!videoId) return;
|
|
103
|
-
|
|
104
|
-
const progress = player.storage.getWatchProgress(videoId);
|
|
105
|
-
if (!progress) return;
|
|
106
|
-
|
|
107
|
-
const { currentTime, duration, percentage } = progress;
|
|
108
|
-
const threshold = player.options.resumeThreshold as number;
|
|
109
|
-
|
|
110
|
-
// Stale / trivial entries get purged instead of offered.
|
|
111
|
-
if (currentTime < threshold || percentage > 95) {
|
|
112
|
-
player.storage.clearWatchProgress(videoId);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Duration mismatch usually means the source URL points to a new
|
|
117
|
-
// cut of the same video — better to start fresh than resume at a
|
|
118
|
-
// time that's outside the new clip.
|
|
119
|
-
if (player.state.duration > 0 && Math.abs(player.state.duration - duration) > 5) {
|
|
120
|
-
player.storage.clearWatchProgress(videoId);
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (player.options.resumePrompt) {
|
|
125
|
-
this.showPrompt(currentTime);
|
|
126
|
-
} else {
|
|
127
|
-
player.seek(currentTime);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Format a time value as `mm:ss` (or `hh:mm:ss` once we cross an
|
|
133
|
-
* hour) for display in the resume prompt label. No localisation is
|
|
134
|
-
* needed because the surrounding prompt text is already localised
|
|
135
|
-
* by i18n.
|
|
136
|
-
*/
|
|
137
|
-
private formatTime(seconds: number): string {
|
|
138
|
-
const h = Math.floor(seconds / 3600);
|
|
139
|
-
const m = Math.floor((seconds % 3600) / 60);
|
|
140
|
-
const s = Math.floor(seconds % 60);
|
|
141
|
-
|
|
142
|
-
if (h > 0) {
|
|
143
|
-
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
144
|
-
}
|
|
145
|
-
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
showPrompt(savedTime: number): void {
|
|
149
|
-
const player = this.player;
|
|
150
|
-
if (player.state.resumePromptVisible || !player.container) return;
|
|
151
|
-
|
|
152
|
-
const formattedTime = this.formatTime(savedTime);
|
|
153
|
-
const promptText = i18n.t('resume.prompt', { time: formattedTime });
|
|
154
|
-
|
|
155
|
-
player.resumePromptElement = DOMUtils.createElement('div', {
|
|
156
|
-
className: `${player.options.classPrefix}-resume-prompt`,
|
|
157
|
-
attributes: {
|
|
158
|
-
role: 'dialog',
|
|
159
|
-
'aria-label': promptText,
|
|
160
|
-
'aria-modal': 'true'
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
const promptContent = DOMUtils.createElement('div', {
|
|
165
|
-
className: `${player.options.classPrefix}-resume-prompt-content`
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const promptMessage = DOMUtils.createElement('p', {
|
|
169
|
-
className: `${player.options.classPrefix}-resume-prompt-message`,
|
|
170
|
-
textContent: promptText
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
const buttonContainer = DOMUtils.createElement('div', {
|
|
174
|
-
className: `${player.options.classPrefix}-resume-prompt-buttons`
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
const resumeButton = DOMUtils.createElement('button', {
|
|
178
|
-
className: `${player.options.classPrefix}-resume-prompt-button ${player.options.classPrefix}-resume-prompt-button-primary`,
|
|
179
|
-
textContent: i18n.t('resume.resume'),
|
|
180
|
-
attributes: { type: 'button' }
|
|
181
|
-
});
|
|
182
|
-
resumeButton.addEventListener('click', () => {
|
|
183
|
-
this.hidePrompt();
|
|
184
|
-
player.seek(savedTime);
|
|
185
|
-
player.play();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
const startOverButton = DOMUtils.createElement('button', {
|
|
189
|
-
className: `${player.options.classPrefix}-resume-prompt-button`,
|
|
190
|
-
textContent: i18n.t('resume.startOver'),
|
|
191
|
-
attributes: { type: 'button' }
|
|
192
|
-
});
|
|
193
|
-
startOverButton.addEventListener('click', () => {
|
|
194
|
-
this.hidePrompt();
|
|
195
|
-
const videoId = player.getVideoId();
|
|
196
|
-
if (videoId) player.storage.clearWatchProgress(videoId);
|
|
197
|
-
player.seek(0);
|
|
198
|
-
player.play();
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
const handleKeydown = (e: KeyboardEvent) => {
|
|
202
|
-
if (e.key === 'Escape') {
|
|
203
|
-
e.preventDefault();
|
|
204
|
-
e.stopPropagation();
|
|
205
|
-
this.hidePrompt();
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
player.resumePromptElement.addEventListener('keydown', handleKeydown);
|
|
209
|
-
|
|
210
|
-
buttonContainer.appendChild(resumeButton);
|
|
211
|
-
buttonContainer.appendChild(startOverButton);
|
|
212
|
-
promptContent.appendChild(promptMessage);
|
|
213
|
-
promptContent.appendChild(buttonContainer);
|
|
214
|
-
player.resumePromptElement.appendChild(promptContent);
|
|
215
|
-
|
|
216
|
-
player.container.appendChild(player.resumePromptElement);
|
|
217
|
-
player.state.resumePromptVisible = true;
|
|
218
|
-
|
|
219
|
-
// Focus the primary button after the element is attached to the DOM
|
|
220
|
-
// so the dialog is immediately keyboard-navigable.
|
|
221
|
-
requestAnimationFrame(() => { resumeButton.focus(); });
|
|
222
|
-
|
|
223
|
-
player.emit('resumepromptshow', { savedTime });
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
hidePrompt(): void {
|
|
227
|
-
const player = this.player;
|
|
228
|
-
if (!player.resumePromptElement) return;
|
|
229
|
-
|
|
230
|
-
player.resumePromptElement.remove();
|
|
231
|
-
player.resumePromptElement = null;
|
|
232
|
-
player.state.resumePromptVisible = false;
|
|
233
|
-
|
|
234
|
-
player.emit('resumeprompthide');
|
|
235
|
-
}
|
|
236
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Resume-playback feature extracted from Player.
|
|
3
|
+
*
|
|
4
|
+
* Owns:
|
|
5
|
+
* - Throttled progress persistence during playback.
|
|
6
|
+
* - "Where were we?" detection on `loadedmetadata`.
|
|
7
|
+
* - Clearing persisted progress on `ended`.
|
|
8
|
+
* - The optional resume prompt (accessible modal with two buttons).
|
|
9
|
+
*
|
|
10
|
+
* Because `resumePlayback` is opt-in, the Player only instantiates
|
|
11
|
+
* this manager when the option is enabled — so pages without the
|
|
12
|
+
* feature don't pay for the extra state / event handlers.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { DOMUtils } from '../utils/DOMUtils.js';
|
|
16
|
+
import { i18n } from '../i18n/i18n.js';
|
|
17
|
+
import { throttle } from '../utils/PerformanceUtils.js';
|
|
18
|
+
import type { Player } from './Player.js';
|
|
19
|
+
|
|
20
|
+
export class ResumeManager {
|
|
21
|
+
private readonly player: Player;
|
|
22
|
+
private saveProgressThrottled: (() => void) | null = null;
|
|
23
|
+
private resumeChecked = false;
|
|
24
|
+
private listenersAttached = false;
|
|
25
|
+
|
|
26
|
+
constructor(player: Player) {
|
|
27
|
+
this.player = player;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Wire up the progress-save + resume-check listeners. Safe to call
|
|
32
|
+
* multiple times: repeat calls are no-ops so a re-init path during
|
|
33
|
+
* source switching doesn't stack duplicate listeners.
|
|
34
|
+
*/
|
|
35
|
+
init(): void {
|
|
36
|
+
if (this.listenersAttached) return;
|
|
37
|
+
this.listenersAttached = true;
|
|
38
|
+
|
|
39
|
+
this.saveProgressThrottled = throttle(() => this.saveProgress(), 5000);
|
|
40
|
+
|
|
41
|
+
this.player.on('timeupdate', () => {
|
|
42
|
+
if (this.player.state.playing && this.player.state.duration > 0) {
|
|
43
|
+
this.saveProgressThrottled?.();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// First `loadedmetadata` after init is the only place we should
|
|
48
|
+
// offer to resume — after that the user is in charge of seeking.
|
|
49
|
+
this.player.on('loadedmetadata', () => {
|
|
50
|
+
if (!this.resumeChecked) {
|
|
51
|
+
this.resumeChecked = true;
|
|
52
|
+
this.checkForResume();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.player.on('ended', () => {
|
|
57
|
+
const videoId = this.player.getVideoId();
|
|
58
|
+
if (videoId) {
|
|
59
|
+
this.player.storage.clearWatchProgress(videoId);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Persist current playback progress to storage. No-op when the
|
|
66
|
+
* feature is disabled, when the video is too short / at the very
|
|
67
|
+
* start, or when playback is effectively complete.
|
|
68
|
+
*/
|
|
69
|
+
saveProgress(): void {
|
|
70
|
+
const player = this.player;
|
|
71
|
+
if (!player.options.resumePlayback) return;
|
|
72
|
+
|
|
73
|
+
const videoId = player.getVideoId();
|
|
74
|
+
if (!videoId) return;
|
|
75
|
+
|
|
76
|
+
const currentTime = player.state.currentTime;
|
|
77
|
+
const duration = player.state.duration;
|
|
78
|
+
|
|
79
|
+
// Resume for tiny clips is more annoying than useful.
|
|
80
|
+
if (duration < 30 || currentTime < (player.options.resumeThreshold as number)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Near the end? Don't persist — the user effectively finished.
|
|
85
|
+
const percentage = (currentTime / duration) * 100;
|
|
86
|
+
if (percentage > 95) return;
|
|
87
|
+
|
|
88
|
+
player.storage.saveWatchProgress(videoId, currentTime, duration);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check for a previously-saved resume point for the current video
|
|
93
|
+
* and either auto-resume or show the prompt depending on
|
|
94
|
+
* `options.resumePrompt`. Safe to call manually, e.g. after an
|
|
95
|
+
* external source change.
|
|
96
|
+
*/
|
|
97
|
+
checkForResume(): void {
|
|
98
|
+
const player = this.player;
|
|
99
|
+
if (!player.options.resumePlayback) return;
|
|
100
|
+
|
|
101
|
+
const videoId = player.getVideoId();
|
|
102
|
+
if (!videoId) return;
|
|
103
|
+
|
|
104
|
+
const progress = player.storage.getWatchProgress(videoId);
|
|
105
|
+
if (!progress) return;
|
|
106
|
+
|
|
107
|
+
const { currentTime, duration, percentage } = progress;
|
|
108
|
+
const threshold = player.options.resumeThreshold as number;
|
|
109
|
+
|
|
110
|
+
// Stale / trivial entries get purged instead of offered.
|
|
111
|
+
if (currentTime < threshold || percentage > 95) {
|
|
112
|
+
player.storage.clearWatchProgress(videoId);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Duration mismatch usually means the source URL points to a new
|
|
117
|
+
// cut of the same video — better to start fresh than resume at a
|
|
118
|
+
// time that's outside the new clip.
|
|
119
|
+
if (player.state.duration > 0 && Math.abs(player.state.duration - duration) > 5) {
|
|
120
|
+
player.storage.clearWatchProgress(videoId);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (player.options.resumePrompt) {
|
|
125
|
+
this.showPrompt(currentTime);
|
|
126
|
+
} else {
|
|
127
|
+
player.seek(currentTime);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Format a time value as `mm:ss` (or `hh:mm:ss` once we cross an
|
|
133
|
+
* hour) for display in the resume prompt label. No localisation is
|
|
134
|
+
* needed because the surrounding prompt text is already localised
|
|
135
|
+
* by i18n.
|
|
136
|
+
*/
|
|
137
|
+
private formatTime(seconds: number): string {
|
|
138
|
+
const h = Math.floor(seconds / 3600);
|
|
139
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
140
|
+
const s = Math.floor(seconds % 60);
|
|
141
|
+
|
|
142
|
+
if (h > 0) {
|
|
143
|
+
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
144
|
+
}
|
|
145
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
showPrompt(savedTime: number): void {
|
|
149
|
+
const player = this.player;
|
|
150
|
+
if (player.state.resumePromptVisible || !player.container) return;
|
|
151
|
+
|
|
152
|
+
const formattedTime = this.formatTime(savedTime);
|
|
153
|
+
const promptText = i18n.t('resume.prompt', { time: formattedTime });
|
|
154
|
+
|
|
155
|
+
player.resumePromptElement = DOMUtils.createElement('div', {
|
|
156
|
+
className: `${player.options.classPrefix}-resume-prompt`,
|
|
157
|
+
attributes: {
|
|
158
|
+
role: 'dialog',
|
|
159
|
+
'aria-label': promptText,
|
|
160
|
+
'aria-modal': 'true'
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const promptContent = DOMUtils.createElement('div', {
|
|
165
|
+
className: `${player.options.classPrefix}-resume-prompt-content`
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const promptMessage = DOMUtils.createElement('p', {
|
|
169
|
+
className: `${player.options.classPrefix}-resume-prompt-message`,
|
|
170
|
+
textContent: promptText
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const buttonContainer = DOMUtils.createElement('div', {
|
|
174
|
+
className: `${player.options.classPrefix}-resume-prompt-buttons`
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const resumeButton = DOMUtils.createElement('button', {
|
|
178
|
+
className: `${player.options.classPrefix}-resume-prompt-button ${player.options.classPrefix}-resume-prompt-button-primary`,
|
|
179
|
+
textContent: i18n.t('resume.resume'),
|
|
180
|
+
attributes: { type: 'button' }
|
|
181
|
+
});
|
|
182
|
+
resumeButton.addEventListener('click', () => {
|
|
183
|
+
this.hidePrompt();
|
|
184
|
+
player.seek(savedTime);
|
|
185
|
+
player.play();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const startOverButton = DOMUtils.createElement('button', {
|
|
189
|
+
className: `${player.options.classPrefix}-resume-prompt-button`,
|
|
190
|
+
textContent: i18n.t('resume.startOver'),
|
|
191
|
+
attributes: { type: 'button' }
|
|
192
|
+
});
|
|
193
|
+
startOverButton.addEventListener('click', () => {
|
|
194
|
+
this.hidePrompt();
|
|
195
|
+
const videoId = player.getVideoId();
|
|
196
|
+
if (videoId) player.storage.clearWatchProgress(videoId);
|
|
197
|
+
player.seek(0);
|
|
198
|
+
player.play();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const handleKeydown = (e: KeyboardEvent) => {
|
|
202
|
+
if (e.key === 'Escape') {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
e.stopPropagation();
|
|
205
|
+
this.hidePrompt();
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
player.resumePromptElement.addEventListener('keydown', handleKeydown);
|
|
209
|
+
|
|
210
|
+
buttonContainer.appendChild(resumeButton);
|
|
211
|
+
buttonContainer.appendChild(startOverButton);
|
|
212
|
+
promptContent.appendChild(promptMessage);
|
|
213
|
+
promptContent.appendChild(buttonContainer);
|
|
214
|
+
player.resumePromptElement.appendChild(promptContent);
|
|
215
|
+
|
|
216
|
+
player.container.appendChild(player.resumePromptElement);
|
|
217
|
+
player.state.resumePromptVisible = true;
|
|
218
|
+
|
|
219
|
+
// Focus the primary button after the element is attached to the DOM
|
|
220
|
+
// so the dialog is immediately keyboard-navigable.
|
|
221
|
+
requestAnimationFrame(() => { resumeButton.focus(); });
|
|
222
|
+
|
|
223
|
+
player.emit('resumepromptshow', { savedTime });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
hidePrompt(): void {
|
|
227
|
+
const player = this.player;
|
|
228
|
+
if (!player.resumePromptElement) return;
|
|
229
|
+
|
|
230
|
+
player.resumePromptElement.remove();
|
|
231
|
+
player.resumePromptElement = null;
|
|
232
|
+
player.state.resumePromptVisible = false;
|
|
233
|
+
|
|
234
|
+
player.emit('resumeprompthide');
|
|
235
|
+
}
|
|
236
|
+
}
|