vidply 1.2.0 → 1.2.3

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.
Files changed (167) hide show
  1. package/README.md +974 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
  23. package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
  24. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
  25. package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
  26. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
  27. package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
  28. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
  30. package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
  32. package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
  34. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
  35. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
  36. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
  37. package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
  38. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
  39. package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
  41. package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
  43. package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +648 -35
  45. package/dist/dev/vidply.esm.js.map +3 -3
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
  47. package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
  49. package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
  50. package/dist/legacy/vidply.js +1062 -34
  51. package/dist/legacy/vidply.js.map +3 -3
  52. package/dist/legacy/vidply.min.js +2 -2
  53. package/dist/legacy/vidply.min.meta.json +127 -55
  54. package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
  66. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
  67. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
  69. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
  72. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
  75. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
  81. package/dist/vidply.css +5852 -5796
  82. package/dist/vidply.esm.min.meta.json +204 -149
  83. package/dist/vidply.min.css +1 -1
  84. package/package.json +11 -11
  85. package/src/controls/CaptionManager.ts +598 -598
  86. package/src/controls/ControlBar.ts +4191 -4159
  87. package/src/controls/KeyboardHelp.ts +326 -0
  88. package/src/controls/KeyboardManager.ts +387 -383
  89. package/src/controls/SettingsDialog.ts +488 -488
  90. package/src/controls/TranscriptManager.ts +2426 -2426
  91. package/src/core/AudioDescriptionManager.ts +863 -774
  92. package/src/core/DescriptionSpeechManager.ts +232 -0
  93. package/src/core/FloatingPlayerManager.ts +674 -674
  94. package/src/core/LazyInit.ts +80 -80
  95. package/src/core/MediaSessionManager.ts +341 -0
  96. package/src/core/MetadataAlertsManager.ts +514 -514
  97. package/src/core/Player.ts +2973 -2872
  98. package/src/core/PosterManager.ts +169 -169
  99. package/src/core/PseudoFullscreen.ts +179 -179
  100. package/src/core/ResponsiveManager.ts +231 -231
  101. package/src/core/ResumeManager.ts +236 -236
  102. package/src/core/SignLanguageManager.ts +1308 -1308
  103. package/src/core/ThemeManager.ts +157 -157
  104. package/src/features/PlaylistManager.ts +1838 -1827
  105. package/src/i18n/i18n.ts +223 -223
  106. package/src/i18n/languages/de.ts +244 -217
  107. package/src/i18n/languages/en.ts +245 -218
  108. package/src/i18n/languages/es.ts +241 -214
  109. package/src/i18n/languages/fr.ts +241 -214
  110. package/src/i18n/languages/ja.ts +241 -214
  111. package/src/icons/Icons.ts +168 -167
  112. package/src/index.ts +196 -193
  113. package/src/renderers/DASHRenderer.ts +1077 -1077
  114. package/src/renderers/HLSRenderer.ts +944 -944
  115. package/src/renderers/HTML5Renderer.ts +389 -389
  116. package/src/renderers/SoundCloudRenderer.ts +437 -437
  117. package/src/renderers/VimeoRenderer.ts +298 -298
  118. package/src/renderers/YouTubeRenderer.ts +290 -290
  119. package/src/styles/vidply.css +5852 -5796
  120. package/src/types/events.ts +128 -122
  121. package/src/types/globals.d.ts +313 -313
  122. package/src/types/options.ts +199 -182
  123. package/src/types/renderer.ts +58 -58
  124. package/src/utils/DOMUtils.ts +281 -281
  125. package/src/utils/DownloadInfo.ts +171 -171
  126. package/src/utils/DraggablePanel.ts +539 -539
  127. package/src/utils/DraggablePanelMenu.ts +195 -195
  128. package/src/utils/DraggableResizable.ts +997 -997
  129. package/src/utils/EventEmitter.ts +59 -59
  130. package/src/utils/FormUtils.ts +95 -95
  131. package/src/utils/MenuFactory.ts +483 -483
  132. package/src/utils/Sanitize.ts +70 -70
  133. package/src/utils/SettingsMenuFactory.ts +289 -289
  134. package/src/utils/StorageManager.ts +287 -287
  135. package/src/utils/TrackLabelUtils.ts +52 -52
  136. package/src/utils/UrlSafe.ts +69 -69
  137. package/src/utils/VideoFrameCapture.ts +114 -114
  138. package/src/utils/WindowComponents.ts +250 -250
  139. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  140. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  141. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  142. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  143. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  144. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  145. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  146. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  147. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  148. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  149. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  151. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  152. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  153. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  154. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  155. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  156. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  157. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  158. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  159. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  160. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  161. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  162. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
  163. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
  165. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
  166. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
  167. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-PRFNESYA.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
+ }