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.
- package/README.md +946 -946
- package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
- package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
- package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
- package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
- package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
- package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
- package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
- package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
- package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
- package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
- package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
- package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
- package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
- package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
- package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
- package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
- package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
- package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
- package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
- package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
- package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
- package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
- package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
- package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
- package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
- package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
- package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
- package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
- package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
- package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
- package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
- package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
- package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
- package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
- package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
- package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
- package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
- package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
- package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
- package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
- package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
- package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
- package/dist/dev/vidply.esm.js +73 -34
- package/dist/dev/vidply.esm.js.map +2 -2
- package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
- package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
- package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
- package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
- package/dist/legacy/vidply.js +345 -32
- package/dist/legacy/vidply.js.map +3 -3
- package/dist/legacy/vidply.min.js +2 -2
- package/dist/legacy/vidply.min.meta.json +66 -47
- package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
- package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
- package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
- package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
- package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
- package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
- package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
- package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
- package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
- package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
- package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
- package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
- package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
- package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
- package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
- package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
- package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
- package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
- package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
- package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
- package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
- package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
- package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
- package/dist/prod/vidply.esm.min.js +2 -2
- package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
- package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
- package/dist/vidply.css +5796 -5796
- package/dist/vidply.esm.min.meta.json +148 -135
- package/package.json +7 -7
- package/src/controls/CaptionManager.ts +598 -598
- package/src/controls/ControlBar.ts +4159 -4159
- package/src/controls/KeyboardManager.ts +383 -383
- package/src/controls/SettingsDialog.ts +488 -488
- package/src/controls/TranscriptManager.ts +2426 -2426
- package/src/core/AudioDescriptionManager.ts +863 -774
- package/src/core/DescriptionSpeechManager.ts +232 -0
- package/src/core/FloatingPlayerManager.ts +674 -674
- package/src/core/LazyInit.ts +80 -80
- package/src/core/MetadataAlertsManager.ts +514 -514
- package/src/core/Player.ts +2915 -2872
- package/src/core/PosterManager.ts +169 -169
- package/src/core/PseudoFullscreen.ts +179 -179
- package/src/core/ResponsiveManager.ts +231 -231
- package/src/core/ResumeManager.ts +236 -236
- package/src/core/SignLanguageManager.ts +1308 -1308
- package/src/core/ThemeManager.ts +157 -157
- package/src/features/PlaylistManager.ts +1838 -1827
- package/src/i18n/i18n.ts +223 -223
- package/src/i18n/languages/de.ts +217 -217
- package/src/i18n/languages/en.ts +218 -218
- package/src/i18n/languages/es.ts +214 -214
- package/src/i18n/languages/fr.ts +214 -214
- package/src/i18n/languages/ja.ts +214 -214
- package/src/icons/Icons.ts +167 -167
- package/src/index.ts +196 -193
- package/src/renderers/DASHRenderer.ts +1077 -1077
- package/src/renderers/HLSRenderer.ts +944 -944
- package/src/renderers/HTML5Renderer.ts +389 -389
- package/src/renderers/SoundCloudRenderer.ts +437 -437
- package/src/renderers/VimeoRenderer.ts +298 -298
- package/src/renderers/YouTubeRenderer.ts +290 -290
- package/src/styles/vidply.css +5796 -5796
- package/src/types/events.ts +124 -122
- package/src/types/globals.d.ts +313 -313
- package/src/types/options.ts +185 -182
- package/src/types/renderer.ts +58 -58
- package/src/utils/DOMUtils.ts +281 -281
- package/src/utils/DownloadInfo.ts +171 -171
- package/src/utils/DraggablePanel.ts +539 -539
- package/src/utils/DraggablePanelMenu.ts +195 -195
- package/src/utils/DraggableResizable.ts +997 -997
- package/src/utils/EventEmitter.ts +59 -59
- package/src/utils/FormUtils.ts +95 -95
- package/src/utils/MenuFactory.ts +483 -483
- package/src/utils/Sanitize.ts +70 -70
- package/src/utils/SettingsMenuFactory.ts +289 -289
- package/src/utils/StorageManager.ts +287 -287
- package/src/utils/TrackLabelUtils.ts +52 -52
- package/src/utils/UrlSafe.ts +69 -69
- package/src/utils/VideoFrameCapture.ts +114 -114
- package/src/utils/WindowComponents.ts +250 -250
- package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
- package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
- package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
- package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
- package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
- package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
- package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
- package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
- package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
- package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
- package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
- package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
- package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
- package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
- package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
- package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
- package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
- package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
- package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
- package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
- package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
- package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
- package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
- /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
- /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
- /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
|
+
|