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,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
+