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,52 +1,52 @@
1
- import { i18n } from '../i18n/i18n.js';
2
-
3
- /**
4
- * Derive a human-readable label for a TextTrack when the source manifest
5
- * (DASH AdaptationSet, HLS rendition, etc.) didn't provide one. dash.js
6
- * falls back to the AdaptationSet @id (typically a digit like "2", "3")
7
- * when no <Label> child element is present, which surfaces as cryptic
8
- * "2"/"3" entries in the captions menu.
9
- *
10
- * dash.js 5.x additionally derives the label as `element.id ?? element.lang`.
11
- * When neither is present in the MPD, the value is JS `null`, and the
12
- * browser's TextTrack API stringifies it to the literal "null". We treat
13
- * "null"/"undefined" (any casing) as placeholders too, and fall back to a
14
- * localized language name via Intl.DisplayNames.
15
- */
16
- export function deriveTrackLabel(
17
- rawLabel: string | null | undefined,
18
- language: string | null | undefined,
19
- fallbackKey = 'player.captions'
20
- ): string {
21
- const cleanLabel = (rawLabel ?? '').trim();
22
- const cleanLang = (language ?? '').trim();
23
-
24
- const looksLikePlaceholder =
25
- cleanLabel === '' ||
26
- /^\d+$/.test(cleanLabel) ||
27
- /^(null|undefined)$/i.test(cleanLabel);
28
- if (!looksLikePlaceholder) {
29
- return cleanLabel;
30
- }
31
-
32
- const cleanLangIsUsable =
33
- cleanLang !== '' && !/^(null|undefined)$/i.test(cleanLang);
34
-
35
- if (cleanLangIsUsable) {
36
- try {
37
- // Use endonyms: each language shown in its own name
38
- // ("English", "Deutsch") rather than translated into the UI
39
- // language ("Englisch", "Deutsch").
40
- const displayNames = new Intl.DisplayNames([cleanLang, 'en'], { type: 'language' });
41
- const name = displayNames.of(cleanLang);
42
- if (name && name.toLowerCase() !== cleanLang.toLowerCase()) {
43
- return name;
44
- }
45
- } catch {
46
- // Intl.DisplayNames may be unavailable — fall through to the code.
47
- }
48
- return cleanLang.toUpperCase();
49
- }
50
-
51
- return i18n.t(fallbackKey);
52
- }
1
+ import { i18n } from '../i18n/i18n.js';
2
+
3
+ /**
4
+ * Derive a human-readable label for a TextTrack when the source manifest
5
+ * (DASH AdaptationSet, HLS rendition, etc.) didn't provide one. dash.js
6
+ * falls back to the AdaptationSet @id (typically a digit like "2", "3")
7
+ * when no <Label> child element is present, which surfaces as cryptic
8
+ * "2"/"3" entries in the captions menu.
9
+ *
10
+ * dash.js 5.x additionally derives the label as `element.id ?? element.lang`.
11
+ * When neither is present in the MPD, the value is JS `null`, and the
12
+ * browser's TextTrack API stringifies it to the literal "null". We treat
13
+ * "null"/"undefined" (any casing) as placeholders too, and fall back to a
14
+ * localized language name via Intl.DisplayNames.
15
+ */
16
+ export function deriveTrackLabel(
17
+ rawLabel: string | null | undefined,
18
+ language: string | null | undefined,
19
+ fallbackKey = 'player.captions'
20
+ ): string {
21
+ const cleanLabel = (rawLabel ?? '').trim();
22
+ const cleanLang = (language ?? '').trim();
23
+
24
+ const looksLikePlaceholder =
25
+ cleanLabel === '' ||
26
+ /^\d+$/.test(cleanLabel) ||
27
+ /^(null|undefined)$/i.test(cleanLabel);
28
+ if (!looksLikePlaceholder) {
29
+ return cleanLabel;
30
+ }
31
+
32
+ const cleanLangIsUsable =
33
+ cleanLang !== '' && !/^(null|undefined)$/i.test(cleanLang);
34
+
35
+ if (cleanLangIsUsable) {
36
+ try {
37
+ // Use endonyms: each language shown in its own name
38
+ // ("English", "Deutsch") rather than translated into the UI
39
+ // language ("Englisch", "Deutsch").
40
+ const displayNames = new Intl.DisplayNames([cleanLang, 'en'], { type: 'language' });
41
+ const name = displayNames.of(cleanLang);
42
+ if (name && name.toLowerCase() !== cleanLang.toLowerCase()) {
43
+ return name;
44
+ }
45
+ } catch {
46
+ // Intl.DisplayNames may be unavailable — fall through to the code.
47
+ }
48
+ return cleanLang.toUpperCase();
49
+ }
50
+
51
+ return i18n.t(fallbackKey);
52
+ }
@@ -1,69 +1,69 @@
1
- /**
2
- * URL sanitisation helpers for values that flow into `<video>.poster`
3
- * attributes or CSS `url(...)` declarations.
4
- *
5
- * Both helpers used to live as file-local functions inside `Player.ts`,
6
- * which meant `PlaylistManager` interpolated raw `track.poster` values
7
- * into `backgroundImage` without any validation. They now live here so
8
- * every consumer of untrusted poster/artwork URLs can share the same
9
- * allow-list + CSS escape.
10
- */
11
-
12
- /**
13
- * Validate a poster/artwork URL before interpolating it into a CSS
14
- * `url(...)` value or assigning it to `<video>.poster`. Allows `https:`,
15
- * `http:`, `data:image/<png|jpeg|webp|gif|svg+xml>;...`, root-relative
16
- * paths starting with `/`, and same-origin relative paths.
17
- *
18
- * Returns `null` for anything suspicious — callers must check for null
19
- * and fall back to a safe default (e.g. skip setting `backgroundImage`).
20
- */
21
- export function sanitizePosterUrl(input: unknown): string | null {
22
- if (typeof input !== 'string' || input.length === 0 || input.length > 4096) {
23
- return null;
24
- }
25
- const trimmed = input.trim();
26
- if (!trimmed) return null;
27
- // Whitespace, quotes, angle brackets and backslashes would let a
28
- // crafted value break out of a CSS declaration or HTML attribute.
29
- if (/[\s"'<>\\]/.test(trimmed)) return null;
30
-
31
- if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
32
- return trimmed;
33
- }
34
- try {
35
- const url = new URL(trimmed, typeof window !== 'undefined' ? window.location.href : 'http://localhost/');
36
- if (url.protocol === 'https:' || url.protocol === 'http:') {
37
- return url.href;
38
- }
39
- if (url.protocol === 'data:' && /^data:image\/(png|jpeg|jpg|webp|gif|svg\+xml);/i.test(trimmed)) {
40
- return trimmed;
41
- }
42
- } catch {
43
- return null;
44
- }
45
- return null;
46
- }
47
-
48
- /**
49
- * CSS-escape an already-validated URL for safe interpolation into a
50
- * `url(...)` value. Defense in depth alongside {@link sanitizePosterUrl}:
51
- * even if the URL slipped through, the CSS parser cannot leave the
52
- * `url()` context because `"`, `(`, `)` and `\` are all escaped.
53
- */
54
- export function cssEscapeUrl(url: string): string {
55
- return url.replace(/["()\\]/g, (m) => `\\${m}`);
56
- }
57
-
58
- /**
59
- * Convenience wrapper: given an unknown input, return a CSS
60
- * `url("escaped-safe-url")` string ready to assign to
61
- * `element.style.backgroundImage`, or `null` if the input is unsafe
62
- * (so the caller can leave the current background untouched instead
63
- * of clearing it).
64
- */
65
- export function toCssBackgroundImage(input: unknown): string | null {
66
- const safe = sanitizePosterUrl(input);
67
- if (!safe) return null;
68
- return `url("${cssEscapeUrl(safe)}")`;
69
- }
1
+ /**
2
+ * URL sanitisation helpers for values that flow into `<video>.poster`
3
+ * attributes or CSS `url(...)` declarations.
4
+ *
5
+ * Both helpers used to live as file-local functions inside `Player.ts`,
6
+ * which meant `PlaylistManager` interpolated raw `track.poster` values
7
+ * into `backgroundImage` without any validation. They now live here so
8
+ * every consumer of untrusted poster/artwork URLs can share the same
9
+ * allow-list + CSS escape.
10
+ */
11
+
12
+ /**
13
+ * Validate a poster/artwork URL before interpolating it into a CSS
14
+ * `url(...)` value or assigning it to `<video>.poster`. Allows `https:`,
15
+ * `http:`, `data:image/<png|jpeg|webp|gif|svg+xml>;...`, root-relative
16
+ * paths starting with `/`, and same-origin relative paths.
17
+ *
18
+ * Returns `null` for anything suspicious — callers must check for null
19
+ * and fall back to a safe default (e.g. skip setting `backgroundImage`).
20
+ */
21
+ export function sanitizePosterUrl(input: unknown): string | null {
22
+ if (typeof input !== 'string' || input.length === 0 || input.length > 4096) {
23
+ return null;
24
+ }
25
+ const trimmed = input.trim();
26
+ if (!trimmed) return null;
27
+ // Whitespace, quotes, angle brackets and backslashes would let a
28
+ // crafted value break out of a CSS declaration or HTML attribute.
29
+ if (/[\s"'<>\\]/.test(trimmed)) return null;
30
+
31
+ if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
32
+ return trimmed;
33
+ }
34
+ try {
35
+ const url = new URL(trimmed, typeof window !== 'undefined' ? window.location.href : 'http://localhost/');
36
+ if (url.protocol === 'https:' || url.protocol === 'http:') {
37
+ return url.href;
38
+ }
39
+ if (url.protocol === 'data:' && /^data:image\/(png|jpeg|jpg|webp|gif|svg\+xml);/i.test(trimmed)) {
40
+ return trimmed;
41
+ }
42
+ } catch {
43
+ return null;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * CSS-escape an already-validated URL for safe interpolation into a
50
+ * `url(...)` value. Defense in depth alongside {@link sanitizePosterUrl}:
51
+ * even if the URL slipped through, the CSS parser cannot leave the
52
+ * `url()` context because `"`, `(`, `)` and `\` are all escaped.
53
+ */
54
+ export function cssEscapeUrl(url: string): string {
55
+ return url.replace(/["()\\]/g, (m) => `\\${m}`);
56
+ }
57
+
58
+ /**
59
+ * Convenience wrapper: given an unknown input, return a CSS
60
+ * `url("escaped-safe-url")` string ready to assign to
61
+ * `element.style.backgroundImage`, or `null` if the input is unsafe
62
+ * (so the caller can leave the current background untouched instead
63
+ * of clearing it).
64
+ */
65
+ export function toCssBackgroundImage(input: unknown): string | null {
66
+ const safe = sanitizePosterUrl(input);
67
+ if (!safe) return null;
68
+ return `url("${cssEscapeUrl(safe)}")`;
69
+ }
@@ -1,114 +1,114 @@
1
- export interface CaptureFrameOptions {
2
- restoreState?: boolean;
3
- quality?: number;
4
- maxWidth?: number;
5
- maxHeight?: number;
6
- }
7
-
8
- export async function captureVideoFrame(
9
- video: HTMLVideoElement | null,
10
- time: number,
11
- options: CaptureFrameOptions = {}
12
- ): Promise<string | null> {
13
- if (!video || video.tagName !== 'VIDEO') {
14
- return null;
15
- }
16
-
17
- const { restoreState = true, quality = 0.9, maxWidth, maxHeight } = options;
18
-
19
- const wasPlaying = !video.paused;
20
- const originalTime = video.currentTime;
21
- const originalMuted = video.muted;
22
-
23
- if (restoreState) {
24
- video.muted = true;
25
- }
26
-
27
- return new Promise<string | null>((resolve) => {
28
- const captureFrame = () => {
29
- try {
30
- let width = video.videoWidth || 640;
31
- let height = video.videoHeight || 360;
32
-
33
- if (maxWidth && width > maxWidth) {
34
- const ratio = maxWidth / width;
35
- width = maxWidth;
36
- height = Math.round(height * ratio);
37
- }
38
- if (maxHeight && height > maxHeight) {
39
- const ratio = maxHeight / height;
40
- height = maxHeight;
41
- width = Math.round(width * ratio);
42
- }
43
-
44
- const canvas = document.createElement('canvas');
45
- canvas.width = width;
46
- canvas.height = height;
47
-
48
- const ctx = canvas.getContext('2d');
49
- if (!ctx) {
50
- resolve(null);
51
- return;
52
- }
53
- ctx.drawImage(video, 0, 0, width, height);
54
-
55
- const dataURL = canvas.toDataURL('image/jpeg', quality);
56
-
57
- if (restoreState) {
58
- video.currentTime = originalTime;
59
- video.muted = originalMuted;
60
- if (wasPlaying && !video.paused) {
61
- // Autoplay can be rejected by browser policy. We log at debug
62
- // level so the noise doesn't reach production consoles, but
63
- // unexpected errors are still observable.
64
- video.play().catch((e: unknown) => {
65
- if (typeof console !== 'undefined' && console.debug) {
66
- console.debug('[VidPly] preview play() rejected:', e);
67
- }
68
- });
69
- }
70
- }
71
-
72
- resolve(dataURL);
73
- } catch (e) {
74
- if (typeof console !== 'undefined' && console.debug) {
75
- console.debug('[VidPly] frame capture failed:', e);
76
- }
77
- if (restoreState) {
78
- video.currentTime = originalTime;
79
- video.muted = originalMuted;
80
- if (wasPlaying && !video.paused) {
81
- video.play().catch((err: unknown) => {
82
- if (typeof console !== 'undefined' && console.debug) {
83
- console.debug('[VidPly] preview play() rejected:', err);
84
- }
85
- });
86
- }
87
- }
88
- resolve(null);
89
- }
90
- };
91
-
92
- const onSeeked = () => {
93
- video.removeEventListener('seeked', onSeeked);
94
- requestAnimationFrame(() => {
95
- requestAnimationFrame(captureFrame);
96
- });
97
- };
98
-
99
- const timeDiff = Math.abs(video.currentTime - time);
100
- if (timeDiff < 0.1 && video.readyState >= 2) {
101
- captureFrame();
102
- } else if (video.readyState >= 1) {
103
- video.addEventListener('seeked', onSeeked);
104
- video.currentTime = time;
105
- } else {
106
- const onLoadedMetadata = () => {
107
- video.removeEventListener('loadedmetadata', onLoadedMetadata);
108
- video.addEventListener('seeked', onSeeked);
109
- video.currentTime = time;
110
- };
111
- video.addEventListener('loadedmetadata', onLoadedMetadata);
112
- }
113
- });
114
- }
1
+ export interface CaptureFrameOptions {
2
+ restoreState?: boolean;
3
+ quality?: number;
4
+ maxWidth?: number;
5
+ maxHeight?: number;
6
+ }
7
+
8
+ export async function captureVideoFrame(
9
+ video: HTMLVideoElement | null,
10
+ time: number,
11
+ options: CaptureFrameOptions = {}
12
+ ): Promise<string | null> {
13
+ if (!video || video.tagName !== 'VIDEO') {
14
+ return null;
15
+ }
16
+
17
+ const { restoreState = true, quality = 0.9, maxWidth, maxHeight } = options;
18
+
19
+ const wasPlaying = !video.paused;
20
+ const originalTime = video.currentTime;
21
+ const originalMuted = video.muted;
22
+
23
+ if (restoreState) {
24
+ video.muted = true;
25
+ }
26
+
27
+ return new Promise<string | null>((resolve) => {
28
+ const captureFrame = () => {
29
+ try {
30
+ let width = video.videoWidth || 640;
31
+ let height = video.videoHeight || 360;
32
+
33
+ if (maxWidth && width > maxWidth) {
34
+ const ratio = maxWidth / width;
35
+ width = maxWidth;
36
+ height = Math.round(height * ratio);
37
+ }
38
+ if (maxHeight && height > maxHeight) {
39
+ const ratio = maxHeight / height;
40
+ height = maxHeight;
41
+ width = Math.round(width * ratio);
42
+ }
43
+
44
+ const canvas = document.createElement('canvas');
45
+ canvas.width = width;
46
+ canvas.height = height;
47
+
48
+ const ctx = canvas.getContext('2d');
49
+ if (!ctx) {
50
+ resolve(null);
51
+ return;
52
+ }
53
+ ctx.drawImage(video, 0, 0, width, height);
54
+
55
+ const dataURL = canvas.toDataURL('image/jpeg', quality);
56
+
57
+ if (restoreState) {
58
+ video.currentTime = originalTime;
59
+ video.muted = originalMuted;
60
+ if (wasPlaying && !video.paused) {
61
+ // Autoplay can be rejected by browser policy. We log at debug
62
+ // level so the noise doesn't reach production consoles, but
63
+ // unexpected errors are still observable.
64
+ video.play().catch((e: unknown) => {
65
+ if (typeof console !== 'undefined' && console.debug) {
66
+ console.debug('[VidPly] preview play() rejected:', e);
67
+ }
68
+ });
69
+ }
70
+ }
71
+
72
+ resolve(dataURL);
73
+ } catch (e) {
74
+ if (typeof console !== 'undefined' && console.debug) {
75
+ console.debug('[VidPly] frame capture failed:', e);
76
+ }
77
+ if (restoreState) {
78
+ video.currentTime = originalTime;
79
+ video.muted = originalMuted;
80
+ if (wasPlaying && !video.paused) {
81
+ video.play().catch((err: unknown) => {
82
+ if (typeof console !== 'undefined' && console.debug) {
83
+ console.debug('[VidPly] preview play() rejected:', err);
84
+ }
85
+ });
86
+ }
87
+ }
88
+ resolve(null);
89
+ }
90
+ };
91
+
92
+ const onSeeked = () => {
93
+ video.removeEventListener('seeked', onSeeked);
94
+ requestAnimationFrame(() => {
95
+ requestAnimationFrame(captureFrame);
96
+ });
97
+ };
98
+
99
+ const timeDiff = Math.abs(video.currentTime - time);
100
+ if (timeDiff < 0.1 && video.readyState >= 2) {
101
+ captureFrame();
102
+ } else if (video.readyState >= 1) {
103
+ video.addEventListener('seeked', onSeeked);
104
+ video.currentTime = time;
105
+ } else {
106
+ const onLoadedMetadata = () => {
107
+ video.removeEventListener('loadedmetadata', onLoadedMetadata);
108
+ video.addEventListener('seeked', onSeeked);
109
+ video.currentTime = time;
110
+ };
111
+ video.addEventListener('loadedmetadata', onLoadedMetadata);
112
+ }
113
+ });
114
+ }