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.
Files changed (164) hide show
  1. package/README.md +946 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
  23. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
  24. package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
  25. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
  26. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
  27. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
  28. package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
  30. package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
  32. package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
  34. package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
  35. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
  36. package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
  37. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
  38. package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
  39. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
  41. package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
  43. package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +73 -34
  45. package/dist/dev/vidply.esm.js.map +2 -2
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
  47. package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
  49. package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
  50. package/dist/legacy/vidply.js +345 -32
  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 +66 -47
  54. package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
  66. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
  67. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
  69. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
  72. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
  75. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
  81. package/dist/vidply.css +5796 -5796
  82. package/dist/vidply.esm.min.meta.json +148 -135
  83. package/package.json +7 -7
  84. package/src/controls/CaptionManager.ts +598 -598
  85. package/src/controls/ControlBar.ts +4159 -4159
  86. package/src/controls/KeyboardManager.ts +383 -383
  87. package/src/controls/SettingsDialog.ts +488 -488
  88. package/src/controls/TranscriptManager.ts +2426 -2426
  89. package/src/core/AudioDescriptionManager.ts +863 -774
  90. package/src/core/DescriptionSpeechManager.ts +232 -0
  91. package/src/core/FloatingPlayerManager.ts +674 -674
  92. package/src/core/LazyInit.ts +80 -80
  93. package/src/core/MetadataAlertsManager.ts +514 -514
  94. package/src/core/Player.ts +2915 -2872
  95. package/src/core/PosterManager.ts +169 -169
  96. package/src/core/PseudoFullscreen.ts +179 -179
  97. package/src/core/ResponsiveManager.ts +231 -231
  98. package/src/core/ResumeManager.ts +236 -236
  99. package/src/core/SignLanguageManager.ts +1308 -1308
  100. package/src/core/ThemeManager.ts +157 -157
  101. package/src/features/PlaylistManager.ts +1838 -1827
  102. package/src/i18n/i18n.ts +223 -223
  103. package/src/i18n/languages/de.ts +217 -217
  104. package/src/i18n/languages/en.ts +218 -218
  105. package/src/i18n/languages/es.ts +214 -214
  106. package/src/i18n/languages/fr.ts +214 -214
  107. package/src/i18n/languages/ja.ts +214 -214
  108. package/src/icons/Icons.ts +167 -167
  109. package/src/index.ts +196 -193
  110. package/src/renderers/DASHRenderer.ts +1077 -1077
  111. package/src/renderers/HLSRenderer.ts +944 -944
  112. package/src/renderers/HTML5Renderer.ts +389 -389
  113. package/src/renderers/SoundCloudRenderer.ts +437 -437
  114. package/src/renderers/VimeoRenderer.ts +298 -298
  115. package/src/renderers/YouTubeRenderer.ts +290 -290
  116. package/src/styles/vidply.css +5796 -5796
  117. package/src/types/events.ts +124 -122
  118. package/src/types/globals.d.ts +313 -313
  119. package/src/types/options.ts +185 -182
  120. package/src/types/renderer.ts +58 -58
  121. package/src/utils/DOMUtils.ts +281 -281
  122. package/src/utils/DownloadInfo.ts +171 -171
  123. package/src/utils/DraggablePanel.ts +539 -539
  124. package/src/utils/DraggablePanelMenu.ts +195 -195
  125. package/src/utils/DraggableResizable.ts +997 -997
  126. package/src/utils/EventEmitter.ts +59 -59
  127. package/src/utils/FormUtils.ts +95 -95
  128. package/src/utils/MenuFactory.ts +483 -483
  129. package/src/utils/Sanitize.ts +70 -70
  130. package/src/utils/SettingsMenuFactory.ts +289 -289
  131. package/src/utils/StorageManager.ts +287 -287
  132. package/src/utils/TrackLabelUtils.ts +52 -52
  133. package/src/utils/UrlSafe.ts +69 -69
  134. package/src/utils/VideoFrameCapture.ts +114 -114
  135. package/src/utils/WindowComponents.ts +250 -250
  136. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  137. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  138. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  139. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  140. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  141. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  142. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  143. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  144. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  145. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  146. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  147. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  148. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  149. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  151. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  152. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  153. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  154. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  155. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  156. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  157. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  158. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  159. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
  160. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
  161. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
  162. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
  163. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-YVCYQZJI.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
+ }