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,287 +1,287 @@
1
- import { shallowSanitize } from './Sanitize.js';
2
-
3
- /**
4
- * Type guard helpers used by the validated `get()` overload below.
5
- * Each guard accepts an unknown JSON value and tells TypeScript whether
6
- * it matches the expected runtime shape.
7
- */
8
- export type Validator<T> = (value: unknown) => value is T;
9
-
10
- interface WatchProgressEntry {
11
- currentTime: number;
12
- duration: number;
13
- percentage: number;
14
- updatedAt: number;
15
- }
16
-
17
- /**
18
- * Loose alias for stored preference payloads. Numeric/string-typed fields
19
- * are validated at the boundary (`isPlainObject`) and clamped where it
20
- * matters (volume, playbackSpeed). Individual properties remain `unknown`
21
- * so callers must narrow before use.
22
- */
23
- export type StoredPreferences = Record<string, unknown>;
24
-
25
- function clamp(n: number, min: number, max: number): number {
26
- if (!Number.isFinite(n)) return min;
27
- if (n < min) return min;
28
- if (n > max) return max;
29
- return n;
30
- }
31
-
32
- function isFiniteNonNegative(value: unknown): value is number {
33
- return typeof value === 'number' && Number.isFinite(value) && value >= 0;
34
- }
35
-
36
- function isWatchProgressEntry(value: unknown): value is WatchProgressEntry {
37
- if (!value || typeof value !== 'object') return false;
38
- const v = value as Record<string, unknown>;
39
- return (
40
- isFiniteNonNegative(v.currentTime) &&
41
- isFiniteNonNegative(v.duration) &&
42
- typeof v.percentage === 'number' &&
43
- Number.isFinite(v.percentage) &&
44
- typeof v.updatedAt === 'number' &&
45
- Number.isFinite(v.updatedAt)
46
- );
47
- }
48
-
49
- export class StorageManager {
50
- private namespace: string;
51
- private storage: Storage | null;
52
-
53
- static MAX_WATCH_PROGRESS_ENTRIES = 100;
54
-
55
- constructor(namespace = 'vidply') {
56
- this.namespace = namespace;
57
- this.storage = this.isStorageAvailable() ? localStorage : null;
58
- }
59
-
60
- /**
61
- * `localStorage` access can throw in private-browsing modes (Safari) and
62
- * is undefined in non-DOM environments. Both are tolerated here so the
63
- * Player still works (without persistence) when storage is unavailable.
64
- */
65
- isStorageAvailable(): boolean {
66
- try {
67
- if (typeof localStorage === 'undefined') return false;
68
- const test = '__storage_test__';
69
- localStorage.setItem(test, test);
70
- localStorage.removeItem(test);
71
- return true;
72
- } catch {
73
- return false;
74
- }
75
- }
76
-
77
- getKey(key: string): string {
78
- return `${this.namespace}_${key}`;
79
- }
80
-
81
- set(key: string, value: unknown): boolean {
82
- if (!this.storage) return false;
83
-
84
- try {
85
- const namespacedKey = this.getKey(key);
86
- this.storage.setItem(namespacedKey, JSON.stringify(value));
87
- return true;
88
- } catch (e) {
89
- console.warn('Failed to save to localStorage:', e);
90
- return false;
91
- }
92
- }
93
-
94
- /**
95
- * Generic get. Accepts an optional `validator` so callers can assert the
96
- * runtime shape of the parsed JSON before trusting it. Falls back to
97
- * `defaultValue` if the payload fails validation.
98
- */
99
- get<T = unknown>(key: string, defaultValue: T | null = null, validator?: Validator<T>): T | null {
100
- if (!this.storage) return defaultValue;
101
-
102
- try {
103
- const namespacedKey = this.getKey(key);
104
- const raw = this.storage.getItem(namespacedKey);
105
- if (raw === null) return defaultValue;
106
- const parsed: unknown = JSON.parse(raw);
107
- if (validator && !validator(parsed)) {
108
- console.warn(`[VidPly] Discarding malformed localStorage payload for "${key}"`);
109
- return defaultValue;
110
- }
111
- return parsed as T;
112
- } catch (e) {
113
- console.warn('Failed to read from localStorage:', e);
114
- return defaultValue;
115
- }
116
- }
117
-
118
- remove(key: string): boolean {
119
- if (!this.storage) return false;
120
-
121
- try {
122
- const namespacedKey = this.getKey(key);
123
- this.storage.removeItem(namespacedKey);
124
- return true;
125
- } catch (e) {
126
- console.warn('Failed to remove from localStorage:', e);
127
- return false;
128
- }
129
- }
130
-
131
- clear(): boolean {
132
- if (!this.storage) return false;
133
-
134
- try {
135
- const storage = this.storage;
136
- const keys = Object.keys(storage);
137
- keys.forEach(key => {
138
- if (key.startsWith(this.namespace)) {
139
- storage.removeItem(key);
140
- }
141
- });
142
- return true;
143
- } catch (e) {
144
- console.warn('Failed to clear localStorage:', e);
145
- return false;
146
- }
147
- }
148
-
149
- saveTranscriptPreferences(preferences: StoredPreferences): boolean {
150
- return this.set('transcript_preferences', shallowSanitize(preferences));
151
- }
152
-
153
- getTranscriptPreferences(): StoredPreferences | null {
154
- return this.get<StoredPreferences>(
155
- 'transcript_preferences',
156
- null,
157
- isPlainObject as Validator<StoredPreferences>
158
- );
159
- }
160
-
161
- saveCaptionPreferences(preferences: StoredPreferences): boolean {
162
- return this.set('caption_preferences', shallowSanitize(preferences));
163
- }
164
-
165
- getCaptionPreferences(): StoredPreferences | null {
166
- return this.get<StoredPreferences>('caption_preferences', null, isPlainObject as Validator<StoredPreferences>);
167
- }
168
-
169
- savePlayerPreferences(preferences: StoredPreferences): boolean {
170
- // Numeric ranges are clamped here so a tampered storage payload cannot
171
- // ship a `volume: 1e9` straight to a renderer.
172
- const sanitized = shallowSanitize(preferences);
173
- if (typeof sanitized.volume === 'number') {
174
- sanitized.volume = clamp(sanitized.volume, 0, 1);
175
- }
176
- if (typeof sanitized.playbackSpeed === 'number') {
177
- sanitized.playbackSpeed = clamp(sanitized.playbackSpeed, 0.1, 4);
178
- }
179
- return this.set('player_preferences', sanitized);
180
- }
181
-
182
- getPlayerPreferences(): StoredPreferences | null {
183
- const value = this.get<StoredPreferences>('player_preferences', null, isPlainObject as Validator<StoredPreferences>);
184
- if (!value) return null;
185
- if (typeof value.volume === 'number') {
186
- value.volume = clamp(value.volume, 0, 1);
187
- }
188
- if (typeof value.playbackSpeed === 'number') {
189
- value.playbackSpeed = clamp(value.playbackSpeed, 0.1, 4);
190
- }
191
- return value;
192
- }
193
-
194
- saveSignLanguagePreferences(preferences: StoredPreferences): boolean {
195
- return this.set('sign_language_preferences', shallowSanitize(preferences));
196
- }
197
-
198
- getSignLanguagePreferences(): StoredPreferences | null {
199
- return this.get<StoredPreferences>('sign_language_preferences', null, isPlainObject as Validator<StoredPreferences>);
200
- }
201
-
202
- saveFloatingPreferences(preferences: StoredPreferences): boolean {
203
- return this.set('floating_preferences', shallowSanitize(preferences));
204
- }
205
-
206
- getFloatingPreferences(): StoredPreferences | null {
207
- return this.get<StoredPreferences>('floating_preferences', null, isPlainObject as Validator<StoredPreferences>);
208
- }
209
-
210
- /**
211
- * Persist watch progress for a video id. Numeric inputs are validated +
212
- * clamped so a caller cannot poison the store with `Infinity`/negatives.
213
- */
214
- saveWatchProgress(videoId: string, currentTime: number, duration: number): boolean {
215
- if (typeof videoId !== 'string' || !videoId) return false;
216
- if (!Number.isFinite(currentTime) || !Number.isFinite(duration) || duration <= 0) return false;
217
-
218
- const safeDuration = clamp(duration, 0.001, 24 * 60 * 60); // cap to 24h to neutralize gigantic numbers
219
- const safeCurrent = clamp(currentTime, 0, safeDuration);
220
-
221
- const allProgress = this.get<Record<string, WatchProgressEntry>>(
222
- 'watch_progress',
223
- Object.create(null),
224
- isWatchProgressMap
225
- ) ?? Object.create(null) as Record<string, WatchProgressEntry>;
226
-
227
- const percentage = (safeCurrent / safeDuration) * 100;
228
-
229
- allProgress[videoId] = {
230
- currentTime: safeCurrent,
231
- duration: safeDuration,
232
- percentage: clamp(percentage, 0, 100),
233
- updatedAt: Date.now()
234
- };
235
-
236
- const entries = Object.entries(allProgress);
237
- if (entries.length > StorageManager.MAX_WATCH_PROGRESS_ENTRIES) {
238
- entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
239
- const toRemove = entries.length - StorageManager.MAX_WATCH_PROGRESS_ENTRIES;
240
- for (let i = 0; i < toRemove; i++) {
241
- const entry = entries[i];
242
- if (entry) {
243
- delete allProgress[entry[0]];
244
- }
245
- }
246
- }
247
-
248
- return this.set('watch_progress', allProgress);
249
- }
250
-
251
- getWatchProgress(videoId: string): WatchProgressEntry | null {
252
- if (!videoId) return null;
253
- const allProgress = this.get<Record<string, WatchProgressEntry>>(
254
- 'watch_progress',
255
- Object.create(null),
256
- isWatchProgressMap
257
- ) ?? Object.create(null) as Record<string, WatchProgressEntry>;
258
- const entry = allProgress[videoId];
259
- return entry && isWatchProgressEntry(entry) ? entry : null;
260
- }
261
-
262
- clearWatchProgress(videoId: string): boolean {
263
- if (!videoId) return false;
264
- const allProgress = this.get<Record<string, WatchProgressEntry>>(
265
- 'watch_progress',
266
- Object.create(null),
267
- isWatchProgressMap
268
- ) ?? Object.create(null) as Record<string, WatchProgressEntry>;
269
- if (allProgress[videoId]) {
270
- delete allProgress[videoId];
271
- return this.set('watch_progress', allProgress);
272
- }
273
- return true;
274
- }
275
- }
276
-
277
- function isPlainObject(value: unknown): value is Record<string, unknown> {
278
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
279
- }
280
-
281
- function isWatchProgressMap(value: unknown): value is Record<string, WatchProgressEntry> {
282
- if (!isPlainObject(value)) return false;
283
- for (const entry of Object.values(value)) {
284
- if (!isWatchProgressEntry(entry)) return false;
285
- }
286
- return true;
287
- }
1
+ import { shallowSanitize } from './Sanitize.js';
2
+
3
+ /**
4
+ * Type guard helpers used by the validated `get()` overload below.
5
+ * Each guard accepts an unknown JSON value and tells TypeScript whether
6
+ * it matches the expected runtime shape.
7
+ */
8
+ export type Validator<T> = (value: unknown) => value is T;
9
+
10
+ interface WatchProgressEntry {
11
+ currentTime: number;
12
+ duration: number;
13
+ percentage: number;
14
+ updatedAt: number;
15
+ }
16
+
17
+ /**
18
+ * Loose alias for stored preference payloads. Numeric/string-typed fields
19
+ * are validated at the boundary (`isPlainObject`) and clamped where it
20
+ * matters (volume, playbackSpeed). Individual properties remain `unknown`
21
+ * so callers must narrow before use.
22
+ */
23
+ export type StoredPreferences = Record<string, unknown>;
24
+
25
+ function clamp(n: number, min: number, max: number): number {
26
+ if (!Number.isFinite(n)) return min;
27
+ if (n < min) return min;
28
+ if (n > max) return max;
29
+ return n;
30
+ }
31
+
32
+ function isFiniteNonNegative(value: unknown): value is number {
33
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
34
+ }
35
+
36
+ function isWatchProgressEntry(value: unknown): value is WatchProgressEntry {
37
+ if (!value || typeof value !== 'object') return false;
38
+ const v = value as Record<string, unknown>;
39
+ return (
40
+ isFiniteNonNegative(v.currentTime) &&
41
+ isFiniteNonNegative(v.duration) &&
42
+ typeof v.percentage === 'number' &&
43
+ Number.isFinite(v.percentage) &&
44
+ typeof v.updatedAt === 'number' &&
45
+ Number.isFinite(v.updatedAt)
46
+ );
47
+ }
48
+
49
+ export class StorageManager {
50
+ private namespace: string;
51
+ private storage: Storage | null;
52
+
53
+ static MAX_WATCH_PROGRESS_ENTRIES = 100;
54
+
55
+ constructor(namespace = 'vidply') {
56
+ this.namespace = namespace;
57
+ this.storage = this.isStorageAvailable() ? localStorage : null;
58
+ }
59
+
60
+ /**
61
+ * `localStorage` access can throw in private-browsing modes (Safari) and
62
+ * is undefined in non-DOM environments. Both are tolerated here so the
63
+ * Player still works (without persistence) when storage is unavailable.
64
+ */
65
+ isStorageAvailable(): boolean {
66
+ try {
67
+ if (typeof localStorage === 'undefined') return false;
68
+ const test = '__storage_test__';
69
+ localStorage.setItem(test, test);
70
+ localStorage.removeItem(test);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ getKey(key: string): string {
78
+ return `${this.namespace}_${key}`;
79
+ }
80
+
81
+ set(key: string, value: unknown): boolean {
82
+ if (!this.storage) return false;
83
+
84
+ try {
85
+ const namespacedKey = this.getKey(key);
86
+ this.storage.setItem(namespacedKey, JSON.stringify(value));
87
+ return true;
88
+ } catch (e) {
89
+ console.warn('Failed to save to localStorage:', e);
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Generic get. Accepts an optional `validator` so callers can assert the
96
+ * runtime shape of the parsed JSON before trusting it. Falls back to
97
+ * `defaultValue` if the payload fails validation.
98
+ */
99
+ get<T = unknown>(key: string, defaultValue: T | null = null, validator?: Validator<T>): T | null {
100
+ if (!this.storage) return defaultValue;
101
+
102
+ try {
103
+ const namespacedKey = this.getKey(key);
104
+ const raw = this.storage.getItem(namespacedKey);
105
+ if (raw === null) return defaultValue;
106
+ const parsed: unknown = JSON.parse(raw);
107
+ if (validator && !validator(parsed)) {
108
+ console.warn(`[VidPly] Discarding malformed localStorage payload for "${key}"`);
109
+ return defaultValue;
110
+ }
111
+ return parsed as T;
112
+ } catch (e) {
113
+ console.warn('Failed to read from localStorage:', e);
114
+ return defaultValue;
115
+ }
116
+ }
117
+
118
+ remove(key: string): boolean {
119
+ if (!this.storage) return false;
120
+
121
+ try {
122
+ const namespacedKey = this.getKey(key);
123
+ this.storage.removeItem(namespacedKey);
124
+ return true;
125
+ } catch (e) {
126
+ console.warn('Failed to remove from localStorage:', e);
127
+ return false;
128
+ }
129
+ }
130
+
131
+ clear(): boolean {
132
+ if (!this.storage) return false;
133
+
134
+ try {
135
+ const storage = this.storage;
136
+ const keys = Object.keys(storage);
137
+ keys.forEach(key => {
138
+ if (key.startsWith(this.namespace)) {
139
+ storage.removeItem(key);
140
+ }
141
+ });
142
+ return true;
143
+ } catch (e) {
144
+ console.warn('Failed to clear localStorage:', e);
145
+ return false;
146
+ }
147
+ }
148
+
149
+ saveTranscriptPreferences(preferences: StoredPreferences): boolean {
150
+ return this.set('transcript_preferences', shallowSanitize(preferences));
151
+ }
152
+
153
+ getTranscriptPreferences(): StoredPreferences | null {
154
+ return this.get<StoredPreferences>(
155
+ 'transcript_preferences',
156
+ null,
157
+ isPlainObject as Validator<StoredPreferences>
158
+ );
159
+ }
160
+
161
+ saveCaptionPreferences(preferences: StoredPreferences): boolean {
162
+ return this.set('caption_preferences', shallowSanitize(preferences));
163
+ }
164
+
165
+ getCaptionPreferences(): StoredPreferences | null {
166
+ return this.get<StoredPreferences>('caption_preferences', null, isPlainObject as Validator<StoredPreferences>);
167
+ }
168
+
169
+ savePlayerPreferences(preferences: StoredPreferences): boolean {
170
+ // Numeric ranges are clamped here so a tampered storage payload cannot
171
+ // ship a `volume: 1e9` straight to a renderer.
172
+ const sanitized = shallowSanitize(preferences);
173
+ if (typeof sanitized.volume === 'number') {
174
+ sanitized.volume = clamp(sanitized.volume, 0, 1);
175
+ }
176
+ if (typeof sanitized.playbackSpeed === 'number') {
177
+ sanitized.playbackSpeed = clamp(sanitized.playbackSpeed, 0.1, 4);
178
+ }
179
+ return this.set('player_preferences', sanitized);
180
+ }
181
+
182
+ getPlayerPreferences(): StoredPreferences | null {
183
+ const value = this.get<StoredPreferences>('player_preferences', null, isPlainObject as Validator<StoredPreferences>);
184
+ if (!value) return null;
185
+ if (typeof value.volume === 'number') {
186
+ value.volume = clamp(value.volume, 0, 1);
187
+ }
188
+ if (typeof value.playbackSpeed === 'number') {
189
+ value.playbackSpeed = clamp(value.playbackSpeed, 0.1, 4);
190
+ }
191
+ return value;
192
+ }
193
+
194
+ saveSignLanguagePreferences(preferences: StoredPreferences): boolean {
195
+ return this.set('sign_language_preferences', shallowSanitize(preferences));
196
+ }
197
+
198
+ getSignLanguagePreferences(): StoredPreferences | null {
199
+ return this.get<StoredPreferences>('sign_language_preferences', null, isPlainObject as Validator<StoredPreferences>);
200
+ }
201
+
202
+ saveFloatingPreferences(preferences: StoredPreferences): boolean {
203
+ return this.set('floating_preferences', shallowSanitize(preferences));
204
+ }
205
+
206
+ getFloatingPreferences(): StoredPreferences | null {
207
+ return this.get<StoredPreferences>('floating_preferences', null, isPlainObject as Validator<StoredPreferences>);
208
+ }
209
+
210
+ /**
211
+ * Persist watch progress for a video id. Numeric inputs are validated +
212
+ * clamped so a caller cannot poison the store with `Infinity`/negatives.
213
+ */
214
+ saveWatchProgress(videoId: string, currentTime: number, duration: number): boolean {
215
+ if (typeof videoId !== 'string' || !videoId) return false;
216
+ if (!Number.isFinite(currentTime) || !Number.isFinite(duration) || duration <= 0) return false;
217
+
218
+ const safeDuration = clamp(duration, 0.001, 24 * 60 * 60); // cap to 24h to neutralize gigantic numbers
219
+ const safeCurrent = clamp(currentTime, 0, safeDuration);
220
+
221
+ const allProgress = this.get<Record<string, WatchProgressEntry>>(
222
+ 'watch_progress',
223
+ Object.create(null),
224
+ isWatchProgressMap
225
+ ) ?? Object.create(null) as Record<string, WatchProgressEntry>;
226
+
227
+ const percentage = (safeCurrent / safeDuration) * 100;
228
+
229
+ allProgress[videoId] = {
230
+ currentTime: safeCurrent,
231
+ duration: safeDuration,
232
+ percentage: clamp(percentage, 0, 100),
233
+ updatedAt: Date.now()
234
+ };
235
+
236
+ const entries = Object.entries(allProgress);
237
+ if (entries.length > StorageManager.MAX_WATCH_PROGRESS_ENTRIES) {
238
+ entries.sort((a, b) => a[1].updatedAt - b[1].updatedAt);
239
+ const toRemove = entries.length - StorageManager.MAX_WATCH_PROGRESS_ENTRIES;
240
+ for (let i = 0; i < toRemove; i++) {
241
+ const entry = entries[i];
242
+ if (entry) {
243
+ delete allProgress[entry[0]];
244
+ }
245
+ }
246
+ }
247
+
248
+ return this.set('watch_progress', allProgress);
249
+ }
250
+
251
+ getWatchProgress(videoId: string): WatchProgressEntry | null {
252
+ if (!videoId) return null;
253
+ const allProgress = this.get<Record<string, WatchProgressEntry>>(
254
+ 'watch_progress',
255
+ Object.create(null),
256
+ isWatchProgressMap
257
+ ) ?? Object.create(null) as Record<string, WatchProgressEntry>;
258
+ const entry = allProgress[videoId];
259
+ return entry && isWatchProgressEntry(entry) ? entry : null;
260
+ }
261
+
262
+ clearWatchProgress(videoId: string): boolean {
263
+ if (!videoId) return false;
264
+ const allProgress = this.get<Record<string, WatchProgressEntry>>(
265
+ 'watch_progress',
266
+ Object.create(null),
267
+ isWatchProgressMap
268
+ ) ?? Object.create(null) as Record<string, WatchProgressEntry>;
269
+ if (allProgress[videoId]) {
270
+ delete allProgress[videoId];
271
+ return this.set('watch_progress', allProgress);
272
+ }
273
+ return true;
274
+ }
275
+ }
276
+
277
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
278
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
279
+ }
280
+
281
+ function isWatchProgressMap(value: unknown): value is Record<string, WatchProgressEntry> {
282
+ if (!isPlainObject(value)) return false;
283
+ for (const entry of Object.values(value)) {
284
+ if (!isWatchProgressEntry(entry)) return false;
285
+ }
286
+ return true;
287
+ }