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,437 +1,437 @@
1
- import type { Renderer } from '../types/renderer.js';
2
- import type { Player } from '../core/Player.js';
3
- import { loadScriptOnce } from '../utils/ScriptLoader.js';
4
-
5
- /**
6
- * Subset of the SoundCloud Widget event payloads we actually consume.
7
- * The Widget API is callback-shaped and does not export TS types, so we
8
- * narrow `unknown` arguments locally as they enter our handlers.
9
- */
10
- interface SCSoundInfo {
11
- duration: number;
12
- title?: string;
13
- }
14
-
15
- interface SCProgressData {
16
- currentPosition: number;
17
- loadedProgress?: number;
18
- }
19
-
20
- interface SCWidgetError {
21
- message?: string;
22
- }
23
-
24
- export class SoundCloudRenderer implements Renderer {
25
- player: Player;
26
- media: HTMLMediaElement;
27
- widget: SCWidget | null;
28
- trackUrl: string | null;
29
- isReady: boolean;
30
- iframe: HTMLIFrameElement | null;
31
- iframeId: string | null;
32
- _previousVolume: number;
33
- // Pending init timeout (rejects after 10s); cleared once READY fires or on
34
- // destroy so it can't reject / touch a torn-down player afterwards.
35
- private _initTimeoutId: ReturnType<typeof setTimeout> | null;
36
- // Detaches the iframe 'load' listener on destroy.
37
- private _initController: AbortController | null;
38
-
39
- constructor(player: Player) {
40
- this.player = player;
41
- this.media = player.element;
42
- this.widget = null;
43
- this.trackUrl = null;
44
- this.isReady = false;
45
- this.iframe = null;
46
- this.iframeId = null;
47
- this._previousVolume = 100;
48
- this._initTimeoutId = null;
49
- this._initController = null;
50
- }
51
-
52
- async init() {
53
- // Extract track URL - use currentSource which works for external renderers
54
- this.trackUrl = this.player.currentSource || this.player.element.src || this.player.element.querySelector('source')?.src || null;
55
-
56
- if (!this.trackUrl || !this.isValidSoundCloudUrl(this.trackUrl)) {
57
- throw new Error('Invalid SoundCloud URL');
58
- }
59
-
60
- // Load SoundCloud Widget API
61
- await this.loadSoundCloudAPI();
62
-
63
- // Create iframe
64
- this.createIframe();
65
-
66
- // Initialize widget
67
- await this.initializeWidget();
68
- }
69
-
70
- /**
71
- * Validate a SoundCloud URL by parsing it with the URL constructor and
72
- * checking the protocol + hostname against an explicit allow-list.
73
- * Substring checks like `url.includes('soundcloud.com')` accept things
74
- * like `https://evil.com/?leak=soundcloud.com` or
75
- * `https://soundcloud.com.attacker.example`.
76
- */
77
- isValidSoundCloudUrl(url: string): boolean {
78
- if (typeof url !== 'string' || !url) return false;
79
- let parsed: URL;
80
- try {
81
- parsed = new URL(url);
82
- } catch {
83
- return false;
84
- }
85
- if (parsed.protocol !== 'https:') return false;
86
- const allowedHosts = new Set([
87
- 'soundcloud.com',
88
- 'www.soundcloud.com',
89
- 'm.soundcloud.com',
90
- 'api.soundcloud.com',
91
- 'api-v2.soundcloud.com'
92
- ]);
93
- return allowedHosts.has(parsed.hostname.toLowerCase());
94
- }
95
-
96
- /**
97
- * Check if URL is a playlist/set
98
- */
99
- isPlaylist() {
100
- return this.trackUrl && this.trackUrl.includes('/sets/');
101
- }
102
-
103
- /**
104
- * Extract track/playlist info from URL for embed
105
- * SoundCloud URLs can be:
106
- * - https://soundcloud.com/artist/track
107
- * - https://soundcloud.com/artist/sets/playlist
108
- * - https://api.soundcloud.com/tracks/123456
109
- */
110
- getEmbedUrl() {
111
- const trackUrl = this.trackUrl;
112
- if (!trackUrl) {
113
- throw new Error('SoundCloudRenderer.getEmbedUrl(): trackUrl is not set');
114
- }
115
-
116
- // Build widget URL with parameters
117
- const params = new URLSearchParams({
118
- url: trackUrl,
119
- auto_play: this.player.options.autoplay ? 'true' : 'false',
120
- hide_related: 'true',
121
- show_comments: 'false',
122
- show_user: 'true',
123
- show_reposts: 'false',
124
- show_teaser: 'false',
125
- visual: 'false', // Use classic player for better control
126
- color: '%23007bff'
127
- });
128
-
129
- return `https://w.soundcloud.com/player/?${params.toString()}`;
130
- }
131
-
132
- async loadSoundCloudAPI() {
133
- // Check if API is already loaded
134
- if (typeof window.SC !== 'undefined') {
135
- return Promise.resolve();
136
- }
137
-
138
- // SC.Widget can become available a tick after the script's load event, so
139
- // resolve via the readiness predicate rather than load alone.
140
- return loadScriptOnce('https://w.soundcloud.com/player/api.js', {
141
- isReady: () => typeof window.SC !== 'undefined'
142
- });
143
- }
144
-
145
- createIframe() {
146
- // Hide original element and remove poster (SoundCloud has its own visual widget)
147
- this.player.element.style.display = 'none';
148
- this.player.element.removeAttribute('poster');
149
-
150
- // Remove poster overlay from video wrapper if present
151
- if (this.player.videoWrapper) {
152
- this.player.videoWrapper.classList.remove('vidply-forced-poster');
153
- this.player.videoWrapper.style.removeProperty('--vidply-poster-image');
154
- }
155
-
156
- // Generate unique ID for iframe
157
- this.iframeId = `soundcloud-player-${Math.random().toString(36).substr(2, 9)}`;
158
-
159
- // Create iframe for SoundCloud widget
160
- this.iframe = document.createElement('iframe');
161
- this.iframe.id = this.iframeId;
162
- this.iframe.scrolling = 'no';
163
- this.iframe.frameBorder = 'no';
164
- this.iframe.allow = 'autoplay';
165
- this.iframe.src = this.getEmbedUrl();
166
- this.iframe.style.width = '100%';
167
- this.iframe.style.display = 'block';
168
-
169
- // Use different aspect ratio for playlists vs single tracks
170
- // Playlists need more height to show the track list
171
- if (this.isPlaylist()) {
172
- this.iframe.classList.add('vidply-soundcloud-iframe', 'vidply-soundcloud-playlist');
173
- } else {
174
- this.iframe.classList.add('vidply-soundcloud-iframe');
175
- }
176
- this.iframe.style.maxHeight = '100%';
177
-
178
- this.player.element.parentNode?.insertBefore(this.iframe, this.player.element);
179
- }
180
-
181
- async initializeWidget() {
182
- return new Promise<void>((resolve, reject) => {
183
- const iframe = this.iframe;
184
- if (!iframe || typeof window.SC === 'undefined') {
185
- reject(new Error('SoundCloud widget cannot initialize'));
186
- return;
187
- }
188
-
189
- const SC = window.SC;
190
- if (!SC) {
191
- reject(new Error('SoundCloud widget cannot initialize'));
192
- return;
193
- }
194
-
195
- // Wait for iframe to load. Registered through an AbortController so
196
- // destroy() can detach it if init never completes.
197
- this._initController = new AbortController();
198
- iframe.addEventListener('load', () => {
199
- try {
200
- const widget = SC.Widget(iframe);
201
- this.widget = widget;
202
-
203
- widget.bind(SC.Widget.Events.READY, () => {
204
- this.isReady = true;
205
- // Init succeeded — cancel the pending timeout so it can't reject.
206
- if (this._initTimeoutId !== null) {
207
- clearTimeout(this._initTimeoutId);
208
- this._initTimeoutId = null;
209
- }
210
- this.attachEvents();
211
-
212
- // Hide VidPly controls - SoundCloud has its own
213
- if (this.player.container) {
214
- this.player.container.classList.add('vidply-external-controls');
215
- }
216
-
217
- // Get initial sound info
218
- widget.getCurrentSound((sound: unknown) => {
219
- const info = sound as SCSoundInfo | null;
220
- if (info) {
221
- this.player.state.duration = info.duration / 1000; // ms -> s
222
- this.player.emit('loadedmetadata');
223
- }
224
- });
225
-
226
- resolve();
227
- });
228
-
229
- widget.bind(SC.Widget.Events.ERROR, (...args: unknown[]) => {
230
- const error = args[0] as SCWidgetError | undefined;
231
- this.player.handleError(new Error(`SoundCloud error: ${error?.message || 'Unknown error'}`));
232
- });
233
- } catch (error) {
234
- reject(error);
235
- }
236
- }, { signal: this._initController.signal });
237
-
238
- // Timeout after 10 seconds
239
- this._initTimeoutId = setTimeout(() => {
240
- this._initTimeoutId = null;
241
- if (!this.isReady) {
242
- reject(new Error('SoundCloud widget initialization timeout'));
243
- }
244
- }, 10000);
245
- });
246
- }
247
-
248
- attachEvents() {
249
- const widget = this.widget;
250
- const SC = window.SC;
251
- if (!widget || !SC) return;
252
-
253
- const Events = SC.Widget.Events;
254
-
255
- widget.bind(Events.PLAY, () => {
256
- this.player.state.playing = true;
257
- this.player.state.paused = false;
258
- this.player.state.ended = false;
259
- this.player.emit('play');
260
-
261
- if (this.player.options.onPlay) {
262
- this.player.options.onPlay.call(this.player);
263
- }
264
- });
265
-
266
- widget.bind(Events.PAUSE, () => {
267
- this.player.state.playing = false;
268
- this.player.state.paused = true;
269
- this.player.emit('pause');
270
-
271
- if (this.player.options.onPause) {
272
- this.player.options.onPause.call(this.player);
273
- }
274
- });
275
-
276
- widget.bind(Events.FINISH, () => {
277
- this.player.state.playing = false;
278
- this.player.state.paused = true;
279
- this.player.state.ended = true;
280
- this.player.emit('ended');
281
-
282
- if (this.player.options.onEnded) {
283
- this.player.options.onEnded.call(this.player);
284
- }
285
-
286
- if (this.player.options.loop) {
287
- this.seek(0);
288
- this.play();
289
- }
290
- });
291
-
292
- widget.bind(Events.PLAY_PROGRESS, (...args: unknown[]) => {
293
- const data = args[0] as SCProgressData;
294
- // data.currentPosition is in milliseconds
295
- const currentTime = data.currentPosition / 1000;
296
- this.player.state.currentTime = currentTime;
297
- this.player.emit('timeupdate', currentTime);
298
-
299
- if (this.player.options.onTimeUpdate) {
300
- this.player.options.onTimeUpdate.call(this.player, currentTime);
301
- }
302
- });
303
-
304
- widget.bind(Events.SEEK, (...args: unknown[]) => {
305
- const data = args[0] as SCProgressData;
306
- this.player.state.currentTime = data.currentPosition / 1000;
307
- this.player.emit('seeked');
308
- });
309
-
310
- widget.bind(Events.LOAD_PROGRESS, (...args: unknown[]) => {
311
- const data = args[0] as SCProgressData;
312
- // data.loadedProgress is 0-1
313
- if (this.player.state.duration && data.loadedProgress !== undefined) {
314
- const buffered = data.loadedProgress * this.player.state.duration;
315
- this.player.emit('progress', buffered);
316
- }
317
- });
318
- }
319
-
320
- play() {
321
- if (this.isReady && this.widget) {
322
- // Save scroll position to prevent browser from scrolling
323
- const scrollX = window.scrollX;
324
- const scrollY = window.scrollY;
325
-
326
- this.widget.play();
327
-
328
- // Restore scroll position
329
- window.scrollTo(scrollX, scrollY);
330
- }
331
- }
332
-
333
- pause() {
334
- if (this.isReady && this.widget) {
335
- this.widget.pause();
336
- }
337
- }
338
-
339
- seek(time: number) {
340
- if (this.isReady && this.widget) {
341
- // SoundCloud seekTo uses milliseconds
342
- this.widget.seekTo(time * 1000);
343
- this.player.state.currentTime = time;
344
- }
345
- }
346
-
347
- setVolume(volume: number) {
348
- if (this.isReady && this.widget) {
349
- // SoundCloud setVolume expects 0-100
350
- this.widget.setVolume(volume * 100);
351
- this.player.state.volume = volume;
352
- }
353
- }
354
-
355
- setMuted(muted: boolean) {
356
- const widget = this.widget;
357
- if (this.isReady && widget) {
358
- // SoundCloud doesn't have a native mute, use volume instead
359
- if (muted) {
360
- // Store current volume before muting
361
- widget.getVolume((vol: number) => {
362
- this._previousVolume = vol;
363
- widget.setVolume(0);
364
- });
365
- } else {
366
- widget.setVolume(this._previousVolume || 100);
367
- }
368
- this.player.state.muted = muted;
369
- }
370
- }
371
-
372
- setPlaybackSpeed(_speed: number) {
373
- // SoundCloud Widget API doesn't support playback speed
374
- this.player.log('SoundCloud does not support playback speed control', 'warn');
375
- }
376
-
377
- /**
378
- * Get current track info. Returns the raw sound payload from the
379
- * SoundCloud Widget API (shape is best described as `unknown` since
380
- * the API exposes many optional fields we don't formally type).
381
- */
382
- getCurrentSound(): Promise<unknown | null> {
383
- return new Promise<unknown | null>((resolve) => {
384
- if (this.isReady && this.widget) {
385
- this.widget.getCurrentSound((sound: unknown) => {
386
- resolve(sound);
387
- });
388
- } else {
389
- resolve(null);
390
- }
391
- });
392
- }
393
-
394
- destroy() {
395
- // Cancel any pending init timeout and detach the iframe 'load' listener.
396
- if (this._initTimeoutId !== null) {
397
- clearTimeout(this._initTimeoutId);
398
- this._initTimeoutId = null;
399
- }
400
- if (this._initController) {
401
- this._initController.abort();
402
- this._initController = null;
403
- }
404
-
405
- // Unbind all events
406
- if (this.widget && window.SC) {
407
- const Events = window.SC.Widget.Events;
408
- try {
409
- this.widget.unbind(Events.READY);
410
- this.widget.unbind(Events.PLAY);
411
- this.widget.unbind(Events.PAUSE);
412
- this.widget.unbind(Events.FINISH);
413
- this.widget.unbind(Events.PLAY_PROGRESS);
414
- this.widget.unbind(Events.SEEK);
415
- this.widget.unbind(Events.LOAD_PROGRESS);
416
- this.widget.unbind(Events.ERROR);
417
- } catch {
418
- // Ignore unbind errors
419
- }
420
- }
421
-
422
- if (this.iframe && this.iframe.parentNode) {
423
- this.iframe.parentNode.removeChild(this.iframe);
424
- }
425
-
426
- // Show original element
427
- if (this.player.element) {
428
- this.player.element.style.display = '';
429
- }
430
-
431
- this.widget = null;
432
- this.isReady = false;
433
- }
434
- }
435
-
436
- export default SoundCloudRenderer;
437
-
1
+ import type { Renderer } from '../types/renderer.js';
2
+ import type { Player } from '../core/Player.js';
3
+ import { loadScriptOnce } from '../utils/ScriptLoader.js';
4
+
5
+ /**
6
+ * Subset of the SoundCloud Widget event payloads we actually consume.
7
+ * The Widget API is callback-shaped and does not export TS types, so we
8
+ * narrow `unknown` arguments locally as they enter our handlers.
9
+ */
10
+ interface SCSoundInfo {
11
+ duration: number;
12
+ title?: string;
13
+ }
14
+
15
+ interface SCProgressData {
16
+ currentPosition: number;
17
+ loadedProgress?: number;
18
+ }
19
+
20
+ interface SCWidgetError {
21
+ message?: string;
22
+ }
23
+
24
+ export class SoundCloudRenderer implements Renderer {
25
+ player: Player;
26
+ media: HTMLMediaElement;
27
+ widget: SCWidget | null;
28
+ trackUrl: string | null;
29
+ isReady: boolean;
30
+ iframe: HTMLIFrameElement | null;
31
+ iframeId: string | null;
32
+ _previousVolume: number;
33
+ // Pending init timeout (rejects after 10s); cleared once READY fires or on
34
+ // destroy so it can't reject / touch a torn-down player afterwards.
35
+ private _initTimeoutId: ReturnType<typeof setTimeout> | null;
36
+ // Detaches the iframe 'load' listener on destroy.
37
+ private _initController: AbortController | null;
38
+
39
+ constructor(player: Player) {
40
+ this.player = player;
41
+ this.media = player.element;
42
+ this.widget = null;
43
+ this.trackUrl = null;
44
+ this.isReady = false;
45
+ this.iframe = null;
46
+ this.iframeId = null;
47
+ this._previousVolume = 100;
48
+ this._initTimeoutId = null;
49
+ this._initController = null;
50
+ }
51
+
52
+ async init() {
53
+ // Extract track URL - use currentSource which works for external renderers
54
+ this.trackUrl = this.player.currentSource || this.player.element.src || this.player.element.querySelector('source')?.src || null;
55
+
56
+ if (!this.trackUrl || !this.isValidSoundCloudUrl(this.trackUrl)) {
57
+ throw new Error('Invalid SoundCloud URL');
58
+ }
59
+
60
+ // Load SoundCloud Widget API
61
+ await this.loadSoundCloudAPI();
62
+
63
+ // Create iframe
64
+ this.createIframe();
65
+
66
+ // Initialize widget
67
+ await this.initializeWidget();
68
+ }
69
+
70
+ /**
71
+ * Validate a SoundCloud URL by parsing it with the URL constructor and
72
+ * checking the protocol + hostname against an explicit allow-list.
73
+ * Substring checks like `url.includes('soundcloud.com')` accept things
74
+ * like `https://evil.com/?leak=soundcloud.com` or
75
+ * `https://soundcloud.com.attacker.example`.
76
+ */
77
+ isValidSoundCloudUrl(url: string): boolean {
78
+ if (typeof url !== 'string' || !url) return false;
79
+ let parsed: URL;
80
+ try {
81
+ parsed = new URL(url);
82
+ } catch {
83
+ return false;
84
+ }
85
+ if (parsed.protocol !== 'https:') return false;
86
+ const allowedHosts = new Set([
87
+ 'soundcloud.com',
88
+ 'www.soundcloud.com',
89
+ 'm.soundcloud.com',
90
+ 'api.soundcloud.com',
91
+ 'api-v2.soundcloud.com'
92
+ ]);
93
+ return allowedHosts.has(parsed.hostname.toLowerCase());
94
+ }
95
+
96
+ /**
97
+ * Check if URL is a playlist/set
98
+ */
99
+ isPlaylist() {
100
+ return this.trackUrl && this.trackUrl.includes('/sets/');
101
+ }
102
+
103
+ /**
104
+ * Extract track/playlist info from URL for embed
105
+ * SoundCloud URLs can be:
106
+ * - https://soundcloud.com/artist/track
107
+ * - https://soundcloud.com/artist/sets/playlist
108
+ * - https://api.soundcloud.com/tracks/123456
109
+ */
110
+ getEmbedUrl() {
111
+ const trackUrl = this.trackUrl;
112
+ if (!trackUrl) {
113
+ throw new Error('SoundCloudRenderer.getEmbedUrl(): trackUrl is not set');
114
+ }
115
+
116
+ // Build widget URL with parameters
117
+ const params = new URLSearchParams({
118
+ url: trackUrl,
119
+ auto_play: this.player.options.autoplay ? 'true' : 'false',
120
+ hide_related: 'true',
121
+ show_comments: 'false',
122
+ show_user: 'true',
123
+ show_reposts: 'false',
124
+ show_teaser: 'false',
125
+ visual: 'false', // Use classic player for better control
126
+ color: '%23007bff'
127
+ });
128
+
129
+ return `https://w.soundcloud.com/player/?${params.toString()}`;
130
+ }
131
+
132
+ async loadSoundCloudAPI() {
133
+ // Check if API is already loaded
134
+ if (typeof window.SC !== 'undefined') {
135
+ return Promise.resolve();
136
+ }
137
+
138
+ // SC.Widget can become available a tick after the script's load event, so
139
+ // resolve via the readiness predicate rather than load alone.
140
+ return loadScriptOnce('https://w.soundcloud.com/player/api.js', {
141
+ isReady: () => typeof window.SC !== 'undefined'
142
+ });
143
+ }
144
+
145
+ createIframe() {
146
+ // Hide original element and remove poster (SoundCloud has its own visual widget)
147
+ this.player.element.style.display = 'none';
148
+ this.player.element.removeAttribute('poster');
149
+
150
+ // Remove poster overlay from video wrapper if present
151
+ if (this.player.videoWrapper) {
152
+ this.player.videoWrapper.classList.remove('vidply-forced-poster');
153
+ this.player.videoWrapper.style.removeProperty('--vidply-poster-image');
154
+ }
155
+
156
+ // Generate unique ID for iframe
157
+ this.iframeId = `soundcloud-player-${Math.random().toString(36).substr(2, 9)}`;
158
+
159
+ // Create iframe for SoundCloud widget
160
+ this.iframe = document.createElement('iframe');
161
+ this.iframe.id = this.iframeId;
162
+ this.iframe.scrolling = 'no';
163
+ this.iframe.frameBorder = 'no';
164
+ this.iframe.allow = 'autoplay';
165
+ this.iframe.src = this.getEmbedUrl();
166
+ this.iframe.style.width = '100%';
167
+ this.iframe.style.display = 'block';
168
+
169
+ // Use different aspect ratio for playlists vs single tracks
170
+ // Playlists need more height to show the track list
171
+ if (this.isPlaylist()) {
172
+ this.iframe.classList.add('vidply-soundcloud-iframe', 'vidply-soundcloud-playlist');
173
+ } else {
174
+ this.iframe.classList.add('vidply-soundcloud-iframe');
175
+ }
176
+ this.iframe.style.maxHeight = '100%';
177
+
178
+ this.player.element.parentNode?.insertBefore(this.iframe, this.player.element);
179
+ }
180
+
181
+ async initializeWidget() {
182
+ return new Promise<void>((resolve, reject) => {
183
+ const iframe = this.iframe;
184
+ if (!iframe || typeof window.SC === 'undefined') {
185
+ reject(new Error('SoundCloud widget cannot initialize'));
186
+ return;
187
+ }
188
+
189
+ const SC = window.SC;
190
+ if (!SC) {
191
+ reject(new Error('SoundCloud widget cannot initialize'));
192
+ return;
193
+ }
194
+
195
+ // Wait for iframe to load. Registered through an AbortController so
196
+ // destroy() can detach it if init never completes.
197
+ this._initController = new AbortController();
198
+ iframe.addEventListener('load', () => {
199
+ try {
200
+ const widget = SC.Widget(iframe);
201
+ this.widget = widget;
202
+
203
+ widget.bind(SC.Widget.Events.READY, () => {
204
+ this.isReady = true;
205
+ // Init succeeded — cancel the pending timeout so it can't reject.
206
+ if (this._initTimeoutId !== null) {
207
+ clearTimeout(this._initTimeoutId);
208
+ this._initTimeoutId = null;
209
+ }
210
+ this.attachEvents();
211
+
212
+ // Hide VidPly controls - SoundCloud has its own
213
+ if (this.player.container) {
214
+ this.player.container.classList.add('vidply-external-controls');
215
+ }
216
+
217
+ // Get initial sound info
218
+ widget.getCurrentSound((sound: unknown) => {
219
+ const info = sound as SCSoundInfo | null;
220
+ if (info) {
221
+ this.player.state.duration = info.duration / 1000; // ms -> s
222
+ this.player.emit('loadedmetadata');
223
+ }
224
+ });
225
+
226
+ resolve();
227
+ });
228
+
229
+ widget.bind(SC.Widget.Events.ERROR, (...args: unknown[]) => {
230
+ const error = args[0] as SCWidgetError | undefined;
231
+ this.player.handleError(new Error(`SoundCloud error: ${error?.message || 'Unknown error'}`));
232
+ });
233
+ } catch (error) {
234
+ reject(error);
235
+ }
236
+ }, { signal: this._initController.signal });
237
+
238
+ // Timeout after 10 seconds
239
+ this._initTimeoutId = setTimeout(() => {
240
+ this._initTimeoutId = null;
241
+ if (!this.isReady) {
242
+ reject(new Error('SoundCloud widget initialization timeout'));
243
+ }
244
+ }, 10000);
245
+ });
246
+ }
247
+
248
+ attachEvents() {
249
+ const widget = this.widget;
250
+ const SC = window.SC;
251
+ if (!widget || !SC) return;
252
+
253
+ const Events = SC.Widget.Events;
254
+
255
+ widget.bind(Events.PLAY, () => {
256
+ this.player.state.playing = true;
257
+ this.player.state.paused = false;
258
+ this.player.state.ended = false;
259
+ this.player.emit('play');
260
+
261
+ if (this.player.options.onPlay) {
262
+ this.player.options.onPlay.call(this.player);
263
+ }
264
+ });
265
+
266
+ widget.bind(Events.PAUSE, () => {
267
+ this.player.state.playing = false;
268
+ this.player.state.paused = true;
269
+ this.player.emit('pause');
270
+
271
+ if (this.player.options.onPause) {
272
+ this.player.options.onPause.call(this.player);
273
+ }
274
+ });
275
+
276
+ widget.bind(Events.FINISH, () => {
277
+ this.player.state.playing = false;
278
+ this.player.state.paused = true;
279
+ this.player.state.ended = true;
280
+ this.player.emit('ended');
281
+
282
+ if (this.player.options.onEnded) {
283
+ this.player.options.onEnded.call(this.player);
284
+ }
285
+
286
+ if (this.player.options.loop) {
287
+ this.seek(0);
288
+ this.play();
289
+ }
290
+ });
291
+
292
+ widget.bind(Events.PLAY_PROGRESS, (...args: unknown[]) => {
293
+ const data = args[0] as SCProgressData;
294
+ // data.currentPosition is in milliseconds
295
+ const currentTime = data.currentPosition / 1000;
296
+ this.player.state.currentTime = currentTime;
297
+ this.player.emit('timeupdate', currentTime);
298
+
299
+ if (this.player.options.onTimeUpdate) {
300
+ this.player.options.onTimeUpdate.call(this.player, currentTime);
301
+ }
302
+ });
303
+
304
+ widget.bind(Events.SEEK, (...args: unknown[]) => {
305
+ const data = args[0] as SCProgressData;
306
+ this.player.state.currentTime = data.currentPosition / 1000;
307
+ this.player.emit('seeked');
308
+ });
309
+
310
+ widget.bind(Events.LOAD_PROGRESS, (...args: unknown[]) => {
311
+ const data = args[0] as SCProgressData;
312
+ // data.loadedProgress is 0-1
313
+ if (this.player.state.duration && data.loadedProgress !== undefined) {
314
+ const buffered = data.loadedProgress * this.player.state.duration;
315
+ this.player.emit('progress', buffered);
316
+ }
317
+ });
318
+ }
319
+
320
+ play() {
321
+ if (this.isReady && this.widget) {
322
+ // Save scroll position to prevent browser from scrolling
323
+ const scrollX = window.scrollX;
324
+ const scrollY = window.scrollY;
325
+
326
+ this.widget.play();
327
+
328
+ // Restore scroll position
329
+ window.scrollTo(scrollX, scrollY);
330
+ }
331
+ }
332
+
333
+ pause() {
334
+ if (this.isReady && this.widget) {
335
+ this.widget.pause();
336
+ }
337
+ }
338
+
339
+ seek(time: number) {
340
+ if (this.isReady && this.widget) {
341
+ // SoundCloud seekTo uses milliseconds
342
+ this.widget.seekTo(time * 1000);
343
+ this.player.state.currentTime = time;
344
+ }
345
+ }
346
+
347
+ setVolume(volume: number) {
348
+ if (this.isReady && this.widget) {
349
+ // SoundCloud setVolume expects 0-100
350
+ this.widget.setVolume(volume * 100);
351
+ this.player.state.volume = volume;
352
+ }
353
+ }
354
+
355
+ setMuted(muted: boolean) {
356
+ const widget = this.widget;
357
+ if (this.isReady && widget) {
358
+ // SoundCloud doesn't have a native mute, use volume instead
359
+ if (muted) {
360
+ // Store current volume before muting
361
+ widget.getVolume((vol: number) => {
362
+ this._previousVolume = vol;
363
+ widget.setVolume(0);
364
+ });
365
+ } else {
366
+ widget.setVolume(this._previousVolume || 100);
367
+ }
368
+ this.player.state.muted = muted;
369
+ }
370
+ }
371
+
372
+ setPlaybackSpeed(_speed: number) {
373
+ // SoundCloud Widget API doesn't support playback speed
374
+ this.player.log('SoundCloud does not support playback speed control', 'warn');
375
+ }
376
+
377
+ /**
378
+ * Get current track info. Returns the raw sound payload from the
379
+ * SoundCloud Widget API (shape is best described as `unknown` since
380
+ * the API exposes many optional fields we don't formally type).
381
+ */
382
+ getCurrentSound(): Promise<unknown | null> {
383
+ return new Promise<unknown | null>((resolve) => {
384
+ if (this.isReady && this.widget) {
385
+ this.widget.getCurrentSound((sound: unknown) => {
386
+ resolve(sound);
387
+ });
388
+ } else {
389
+ resolve(null);
390
+ }
391
+ });
392
+ }
393
+
394
+ destroy() {
395
+ // Cancel any pending init timeout and detach the iframe 'load' listener.
396
+ if (this._initTimeoutId !== null) {
397
+ clearTimeout(this._initTimeoutId);
398
+ this._initTimeoutId = null;
399
+ }
400
+ if (this._initController) {
401
+ this._initController.abort();
402
+ this._initController = null;
403
+ }
404
+
405
+ // Unbind all events
406
+ if (this.widget && window.SC) {
407
+ const Events = window.SC.Widget.Events;
408
+ try {
409
+ this.widget.unbind(Events.READY);
410
+ this.widget.unbind(Events.PLAY);
411
+ this.widget.unbind(Events.PAUSE);
412
+ this.widget.unbind(Events.FINISH);
413
+ this.widget.unbind(Events.PLAY_PROGRESS);
414
+ this.widget.unbind(Events.SEEK);
415
+ this.widget.unbind(Events.LOAD_PROGRESS);
416
+ this.widget.unbind(Events.ERROR);
417
+ } catch {
418
+ // Ignore unbind errors
419
+ }
420
+ }
421
+
422
+ if (this.iframe && this.iframe.parentNode) {
423
+ this.iframe.parentNode.removeChild(this.iframe);
424
+ }
425
+
426
+ // Show original element
427
+ if (this.player.element) {
428
+ this.player.element.style.display = '';
429
+ }
430
+
431
+ this.widget = null;
432
+ this.isReady = false;
433
+ }
434
+ }
435
+
436
+ export default SoundCloudRenderer;
437
+