@mywallpaper/addon-sdk 2.6.1 → 2.8.0

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.
@@ -0,0 +1,627 @@
1
+ /* eslint-disable no-var, curly, object-shorthand */
2
+ /**
3
+ * MyWallpaper Addon SDK v2.0
4
+ *
5
+ * API disponible pour les addons:
6
+ * - window.MyWallpaper.config → Configuration actuelle
7
+ * - window.MyWallpaper.layerId → ID du layer
8
+ * - window.MyWallpaper.onSettingsChange(fn) → Callback hot-reload
9
+ * - window.MyWallpaper.onEvent(type, fn) → Événements système
10
+ * - window.MyWallpaper.ready(options) → Signal prêt
11
+ * - window.MyWallpaper.renderComplete() → Signal rendu visuel terminé
12
+ *
13
+ * Note: Uses 'var' intentionally for maximum iframe/browser compatibility
14
+ */
15
+ ; (function () {
16
+ 'use strict'
17
+
18
+ var layerId = window.__MYWALLPAPER_LAYER_ID__ || ''
19
+ var config = window.__MYWALLPAPER_INITIAL_CONFIG__ || {}
20
+ var isReady = false
21
+ var isRenderComplete = false
22
+
23
+ var settingsCallbacks = []
24
+ var eventCallbacks = new Map()
25
+ var lifecycleCallbacks = { mount: [], unmount: [], pause: [], resume: [] }
26
+ var pendingStorage = new Map()
27
+ var pendingPermissions = new Map()
28
+ var pendingNetwork = new Map()
29
+ var pendingOAuth = new Map()
30
+ var pendingOAuthScopes = new Map()
31
+ var pendingFileAccess = new Map()
32
+ var grantedBlobUrls = new Map() // settingKey -> blobUrl
33
+
34
+ // Audio state (synced from host)
35
+ var audioState = {
36
+ status: 'none',
37
+ currentTime: 0,
38
+ duration: 0,
39
+ volume: 1,
40
+ loop: false,
41
+ playbackRate: 1
42
+ }
43
+ var audioCallbacks = new Set()
44
+
45
+ // Get the original fetch saved by network-sandbox.js (before it was blocked)
46
+ var originalFetch = window.__MYWALLPAPER_ORIGINAL_FETCH__ || null
47
+
48
+ // Message handler
49
+ function onMessage(e) {
50
+ if (e.source !== window.parent) return
51
+ var d = e.data
52
+ if (!d || d.source !== 'MyWallpaperHost') return
53
+
54
+ switch (d.type) {
55
+ case 'SETTINGS_UPDATE':
56
+ var p = d.payload || d
57
+ config = p.settings || p
58
+ var keys = p.changedKeys || Object.keys(config)
59
+ settingsCallbacks.forEach(function (cb) {
60
+ try { cb(config, keys) } catch (err) { console.error('[MyWallpaper]', err) }
61
+ })
62
+ break
63
+
64
+ case 'SYSTEM_EVENT':
65
+ var cbs = eventCallbacks.get(d.payload.event)
66
+ if (cbs) cbs.forEach(function (cb) {
67
+ try { cb(d.payload.data) } catch (err) { console.error('[MyWallpaper]', err) }
68
+ })
69
+ break
70
+
71
+ case 'LIFECYCLE':
72
+ var lcbs = lifecycleCallbacks[d.payload.event]
73
+ if (lcbs) lcbs.forEach(function (cb) {
74
+ try { cb() } catch (err) { console.error('[MyWallpaper]', err) }
75
+ })
76
+ break
77
+
78
+ case 'STORAGE_RESPONSE':
79
+ var op = pendingStorage.get(d.payload.requestId)
80
+ if (op) {
81
+ pendingStorage.delete(d.payload.requestId)
82
+ d.payload.success ? op.resolve(d.payload.value) : op.reject(new Error(d.payload.error))
83
+ }
84
+ break
85
+
86
+ case 'PERMISSION_GRANT':
87
+ var permOp = pendingPermissions.get(d.payload.requestId)
88
+ if (permOp) {
89
+ pendingPermissions.delete(d.payload.requestId)
90
+ permOp(d.payload.granted === true)
91
+ }
92
+ break
93
+
94
+ case 'NETWORK_RESPONSE':
95
+ var netOp = pendingNetwork.get(d.payload.requestId)
96
+ if (netOp) {
97
+ pendingNetwork.delete(d.payload.requestId)
98
+ if (d.payload.success) {
99
+ netOp.resolve({
100
+ ok: d.payload.status >= 200 && d.payload.status < 300,
101
+ status: d.payload.status || 200,
102
+ statusText: d.payload.statusText || 'OK',
103
+ headers: d.payload.headers || {},
104
+ data: d.payload.data
105
+ })
106
+ } else {
107
+ netOp.reject(new Error(d.payload.error || 'Network request failed'))
108
+ }
109
+ }
110
+ break
111
+
112
+ case 'OAUTH_RESPONSE':
113
+ var oauthOp = pendingOAuth.get(d.payload.requestId)
114
+ if (oauthOp) {
115
+ pendingOAuth.delete(d.payload.requestId)
116
+ if (d.payload.success) {
117
+ // Check if it's a scopes error
118
+ if (d.payload.error === 'insufficient_scopes') {
119
+ oauthOp.resolve({
120
+ error: 'insufficient_scopes',
121
+ message: d.payload.message,
122
+ provider: d.payload.provider,
123
+ missingScopes: d.payload.missing_scopes || d.payload.missingScopes || [],
124
+ requiredScopes: d.payload.required_scopes || d.payload.requiredScopes || []
125
+ })
126
+ } else {
127
+ oauthOp.resolve({
128
+ ok: d.payload.status >= 200 && d.payload.status < 300,
129
+ status: d.payload.status || 200,
130
+ headers: d.payload.headers || {},
131
+ data: d.payload.data
132
+ })
133
+ }
134
+ } else {
135
+ // Check if it's a scopes error from failed request
136
+ if (d.payload.error === 'insufficient_scopes' ||
137
+ (d.payload.data && d.payload.data.error === 'insufficient_scopes')) {
138
+ var scopesData = d.payload.data || d.payload
139
+ oauthOp.resolve({
140
+ error: 'insufficient_scopes',
141
+ message: scopesData.message || 'Insufficient OAuth scopes',
142
+ provider: scopesData.provider,
143
+ missingScopes: scopesData.missing_scopes || scopesData.missingScopes || [],
144
+ requiredScopes: scopesData.required_scopes || scopesData.requiredScopes || []
145
+ })
146
+ } else {
147
+ oauthOp.reject(new Error(d.payload.error || 'OAuth request failed'))
148
+ }
149
+ }
150
+ }
151
+ break
152
+
153
+ case 'OAUTH_SCOPES_GRANT':
154
+ var scopeResolver = pendingOAuthScopes.get(d.payload.requestId)
155
+ if (scopeResolver) {
156
+ pendingOAuthScopes.delete(d.payload.requestId)
157
+ scopeResolver(d.payload.granted === true)
158
+ }
159
+ break
160
+
161
+ case 'FILE_ACCESS_RESPONSE':
162
+ // Handle file access response from host
163
+ // Host sends us a Blob object (cloned via structured clone)
164
+ // We create our own blob URL from it
165
+ var fileOp = pendingFileAccess.get(d.requestId)
166
+ if (fileOp) {
167
+ pendingFileAccess.delete(d.requestId)
168
+ if (d.granted && d.blob instanceof Blob) {
169
+ // Create our own blob URL from the received Blob
170
+ var blobUrl = URL.createObjectURL(d.blob)
171
+ grantedBlobUrls.set(d.settingKey, blobUrl)
172
+ fileOp.resolve({ granted: true, blobUrl: blobUrl })
173
+ } else {
174
+ fileOp.resolve({ granted: false, error: d.error || 'Access denied' })
175
+ }
176
+ }
177
+ break
178
+
179
+ case 'AUDIO_STATE':
180
+ // Sync audio state from host
181
+ audioState = d.payload || d
182
+ audioCallbacks.forEach(function (cb) {
183
+ try { cb(audioState) } catch (err) { console.error('[MyWallpaper] Audio callback error:', err) }
184
+ })
185
+ break
186
+ }
187
+ }
188
+
189
+ function send(type, payload) {
190
+ window.parent.postMessage({ type: type, layerId: layerId, timestamp: Date.now(), payload: payload }, '*')
191
+ }
192
+
193
+ function storageOp(op, key, value) {
194
+ return new Promise(function (resolve, reject) {
195
+ var id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
196
+ pendingStorage.set(id, { resolve: resolve, reject: reject })
197
+ setTimeout(function () {
198
+ if (pendingStorage.has(id)) {
199
+ pendingStorage.delete(id)
200
+ reject(new Error('Storage timeout'))
201
+ }
202
+ }, 5000)
203
+ send('STORAGE_OPERATION', { operation: op, key: key, value: value, requestId: id })
204
+ })
205
+ }
206
+
207
+ /**
208
+ * Network fetch via secure host proxy
209
+ * This is the ONLY way addons can make network requests
210
+ * Domain must be declared in manifest.json permissions.network.domains
211
+ */
212
+ function networkFetch(url, options) {
213
+ return new Promise(function (resolve, reject) {
214
+ var id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
215
+ pendingNetwork.set(id, { resolve: resolve, reject: reject })
216
+ // 30s timeout for network requests
217
+ setTimeout(function () {
218
+ if (pendingNetwork.has(id)) {
219
+ pendingNetwork.delete(id)
220
+ reject(new Error('Network request timeout (30s)'))
221
+ }
222
+ }, 30000)
223
+ options = options || {}
224
+ send('NETWORK_REQUEST', {
225
+ url: url,
226
+ method: options.method || 'GET',
227
+ headers: options.headers,
228
+ body: options.body,
229
+ requestId: id
230
+ })
231
+ })
232
+ }
233
+
234
+ /**
235
+ * OAuth API request via secure host proxy
236
+ * Makes authenticated API calls to OAuth providers (GitHub, Google, etc.)
237
+ * Tokens are stored securely on the server - never exposed to addons
238
+ *
239
+ * @param {string} provider - OAuth provider ('github', 'google', 'discord', 'spotify', 'twitch')
240
+ * @param {string} endpoint - API endpoint path (e.g., '/user', '/user/repos')
241
+ * @param {object} options - Request options (method, body, headers, requiredScopes)
242
+ */
243
+ function oauthRequest(provider, endpoint, options) {
244
+ return new Promise(function (resolve, reject) {
245
+ var id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
246
+ pendingOAuth.set(id, { resolve: resolve, reject: reject })
247
+ // 30s timeout for OAuth requests
248
+ setTimeout(function () {
249
+ if (pendingOAuth.has(id)) {
250
+ pendingOAuth.delete(id)
251
+ reject(new Error('OAuth request timeout (30s)'))
252
+ }
253
+ }, 30000)
254
+ options = options || {}
255
+ send('OAUTH_REQUEST', {
256
+ provider: provider,
257
+ endpoint: endpoint,
258
+ method: options.method || 'GET',
259
+ body: options.body,
260
+ headers: options.headers,
261
+ requiredScopes: options.requiredScopes || [],
262
+ requestId: id
263
+ })
264
+ })
265
+ }
266
+
267
+ /**
268
+ * Get the scopes granted for a connected OAuth provider
269
+ * Returns an empty array if not connected
270
+ *
271
+ * @param {string} provider - OAuth provider
272
+ * @returns {Promise<string[]>} Array of granted scopes
273
+ */
274
+ function oauthGetScopes(provider) {
275
+ return new Promise(function (resolve, reject) {
276
+ var id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
277
+ var handler = function (e) {
278
+ if (e.source !== window.parent) return
279
+ var d = e.data
280
+ if (!d || d.source !== 'MyWallpaperHost') return
281
+ if (d.type === 'OAUTH_SCOPES_RESPONSE' && d.payload.requestId === id) {
282
+ window.removeEventListener('message', handler)
283
+ if (d.payload.success) {
284
+ resolve(d.payload.scopes || [])
285
+ } else {
286
+ reject(new Error(d.payload.error || 'Failed to get scopes'))
287
+ }
288
+ }
289
+ }
290
+ window.addEventListener('message', handler)
291
+ // 10s timeout
292
+ setTimeout(function () {
293
+ window.removeEventListener('message', handler)
294
+ resolve([]) // Return empty array on timeout (not connected)
295
+ }, 10000)
296
+ send('OAUTH_GET_SCOPES', { provider: provider, requestId: id })
297
+ })
298
+ }
299
+
300
+ /**
301
+ * Request re-authorization with additional scopes
302
+ * Opens the OAuth flow with new scopes while preserving existing ones
303
+ *
304
+ * @param {string} provider - OAuth provider
305
+ * @param {string[]} scopes - Additional scopes to request
306
+ * @param {string} reason - Reason shown to user
307
+ * @returns {Promise<boolean>} True if re-auth was successful
308
+ */
309
+ function oauthRequestScopes(provider, scopes, reason) {
310
+ return new Promise(function (resolve) {
311
+ var requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`
312
+ pendingOAuthScopes.set(requestId, resolve)
313
+ // 60s timeout - user needs time to complete OAuth flow
314
+ setTimeout(function () {
315
+ if (pendingOAuthScopes.has(requestId)) {
316
+ pendingOAuthScopes.delete(requestId)
317
+ resolve(false)
318
+ }
319
+ }, 60000)
320
+ send('OAUTH_REQUEST_SCOPES', { provider: provider, scopes: scopes, reason: reason, requestId: requestId })
321
+ })
322
+ }
323
+
324
+ window.addEventListener('message', onMessage)
325
+
326
+ window.MyWallpaper = {
327
+ get config() { return config },
328
+ layerId: layerId,
329
+ version: '2.5',
330
+
331
+ onSettingsChange: function (fn) { if (typeof fn === 'function') settingsCallbacks.push(fn) },
332
+
333
+ onEvent: function (event, fn) {
334
+ if (typeof fn !== 'function') return
335
+ if (!eventCallbacks.has(event)) eventCallbacks.set(event, new Set())
336
+ eventCallbacks.get(event).add(fn)
337
+ },
338
+
339
+ offEvent: function (event, fn) {
340
+ var cbs = eventCallbacks.get(event)
341
+ if (cbs) cbs.delete(fn)
342
+ },
343
+
344
+ onMount: function (fn) { if (typeof fn === 'function') lifecycleCallbacks.mount.push(fn) },
345
+ onUnmount: function (fn) { if (typeof fn === 'function') lifecycleCallbacks.unmount.push(fn) },
346
+ onPause: function (fn) { if (typeof fn === 'function') lifecycleCallbacks.pause.push(fn) },
347
+ onResume: function (fn) { if (typeof fn === 'function') lifecycleCallbacks.resume.push(fn) },
348
+
349
+ ready: function (opts) {
350
+ if (isReady) return
351
+ isReady = true
352
+ opts = opts || {}
353
+ send('ADDON_READY', {
354
+ version: '2.5',
355
+ capabilities: opts.capabilities || [],
356
+ subscribedEvents: opts.subscribedEvents || []
357
+ })
358
+ },
359
+
360
+ requestPermission: function (perm, reason) {
361
+ return new Promise(function (resolve) {
362
+ var requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`
363
+ pendingPermissions.set(requestId, resolve)
364
+ // 30s timeout - user needs time to see and respond to permission modal
365
+ setTimeout(function () {
366
+ if (pendingPermissions.has(requestId)) {
367
+ pendingPermissions.delete(requestId)
368
+ resolve(false) // Timeout = denied
369
+ }
370
+ }, 30000)
371
+ send('REQUEST_PERMISSION', { permission: perm, reason: reason, requestId: requestId })
372
+ })
373
+ },
374
+
375
+ renderComplete: function () {
376
+ if (isRenderComplete) return
377
+ isRenderComplete = true
378
+ var renderTimeMs = Math.round(performance.now())
379
+ send('RENDER_COMPLETE', { renderTimeMs: renderTimeMs })
380
+ },
381
+
382
+ storage: {
383
+ get: function (k) { return storageOp('get', k) },
384
+ set: function (k, v) { return storageOp('set', k, v) },
385
+ delete: function (k) { return storageOp('delete', k) },
386
+ clear: function () { return storageOp('clear') },
387
+ keys: function () { return storageOp('keys') },
388
+ size: function () { return storageOp('size') }
389
+ },
390
+
391
+ /**
392
+ * Network API - Secure proxy for HTTP requests
393
+ * All requests go through the host which validates domain whitelist
394
+ *
395
+ * @example
396
+ * // manifest.json: "permissions": { "network": { "domains": ["api.weather.com"] } }
397
+ * const response = await MyWallpaper.network.fetch('https://api.weather.com/current')
398
+ * if (response.ok) console.log(response.data)
399
+ */
400
+ network: {
401
+ fetch: networkFetch
402
+ },
403
+
404
+ /**
405
+ * OAuth API - Secure authenticated API calls to OAuth providers
406
+ * Tokens are stored securely on the server - never exposed to addons
407
+ *
408
+ * Supported providers: github, google, discord, spotify, twitch
409
+ *
410
+ * @example
411
+ * // First request permission
412
+ * const granted = await MyWallpaper.requestPermission('oauth:github', 'To display your GitHub profile')
413
+ * if (granted) {
414
+ * // Make authenticated API request
415
+ * const response = await MyWallpaper.oauth.request('github', '/user')
416
+ * if (response.ok) console.log('GitHub user:', response.data)
417
+ *
418
+ * // List repositories (with required scopes)
419
+ * const repos = await MyWallpaper.oauth.request('github', '/user/repos', {
420
+ * requiredScopes: ['repo'] // Will return scope error if missing
421
+ * })
422
+ * if (repos.error === 'insufficient_scopes') {
423
+ * // Request additional scopes
424
+ * const granted = await MyWallpaper.oauth.requestScopes('github', repos.missingScopes, 'Access private repos')
425
+ * if (granted) {
426
+ * // Retry request
427
+ * const repos = await MyWallpaper.oauth.request('github', '/user/repos', { requiredScopes: ['repo'] })
428
+ * }
429
+ * }
430
+ * }
431
+ */
432
+ oauth: {
433
+ request: oauthRequest,
434
+ getScopes: oauthGetScopes,
435
+ requestScopes: oauthRequestScopes,
436
+ isConnected: function (provider) {
437
+ // Check connection by getting scopes - if scopes array is returned, provider is connected
438
+ return oauthGetScopes(provider)
439
+ .then(function (scopes) { return scopes.length > 0 })
440
+ .catch(function () { return false })
441
+ }
442
+ },
443
+
444
+ /**
445
+ * File Access API - Request access to user-uploaded files
446
+ * Files are not sent automatically - the addon must request access
447
+ * A modal is shown to the user EVERY TIME (not persisted)
448
+ *
449
+ * @example
450
+ * const videoRef = MyWallpaper.config.backgroundVideo
451
+ * if (MyWallpaper.files.isFileReference(videoRef)) {
452
+ * const result = await MyWallpaper.files.request('backgroundVideo')
453
+ * if (result.granted) {
454
+ * video.src = result.blobUrl
455
+ * }
456
+ * }
457
+ */
458
+ files: {
459
+ /**
460
+ * Check if a value is a file reference placeholder
461
+ * @param {unknown} value - Any value from config
462
+ * @returns {boolean} true if this is a FileReference
463
+ */
464
+ isFileReference: function (value) {
465
+ return value !== null &&
466
+ typeof value === 'object' &&
467
+ value.__type === 'file-reference' &&
468
+ typeof value.settingKey === 'string'
469
+ },
470
+
471
+ /**
472
+ * Request access to a file from the user
473
+ * Shows a modal asking for permission (every time)
474
+ * @param {string} settingKey - The setting key containing the file reference
475
+ * @returns {Promise<{granted: boolean, blobUrl?: string, error?: string}>}
476
+ */
477
+ request: function (settingKey) {
478
+ return new Promise(function (resolve) {
479
+ var requestId = Date.now() + '-' + Math.random().toString(36).slice(2)
480
+ pendingFileAccess.set(requestId, { resolve: resolve })
481
+ // 60s timeout - user needs time to respond to modal
482
+ setTimeout(function () {
483
+ if (pendingFileAccess.has(requestId)) {
484
+ pendingFileAccess.delete(requestId)
485
+ resolve({ granted: false, error: 'Request timeout' })
486
+ }
487
+ }, 60000)
488
+ send('FILE_ACCESS_REQUEST', { settingKey: settingKey, requestId: requestId })
489
+ })
490
+ },
491
+
492
+ /**
493
+ * Revoke access to a previously granted file
494
+ * The blob URL will be invalidated
495
+ * @param {string} settingKey - The setting key to revoke access for
496
+ */
497
+ revoke: function (settingKey) {
498
+ var blobUrl = grantedBlobUrls.get(settingKey)
499
+ if (blobUrl) {
500
+ URL.revokeObjectURL(blobUrl)
501
+ grantedBlobUrls.delete(settingKey)
502
+ }
503
+ }
504
+ },
505
+
506
+ /**
507
+ * Audio API - Parent-managed audio playback
508
+ * Audio is played by the host (parent window) to bypass iframe sandbox restrictions
509
+ * The addon controls playback via commands, host syncs state back
510
+ *
511
+ * @example
512
+ * // Play audio from a URL
513
+ * MyWallpaper.audio.play('https://example.com/sound.mp3', { volume: 0.8 })
514
+ *
515
+ * // Play audio from a file setting
516
+ * MyWallpaper.audio.play('audioFile') // settingKey
517
+ *
518
+ * // Listen to state changes
519
+ * MyWallpaper.audio.onStateChange(function(state) {
520
+ * console.log('Audio status:', state.status, 'Time:', state.currentTime)
521
+ * })
522
+ */
523
+ audio: {
524
+ /**
525
+ * Play audio from a source
526
+ * @param {string|object} source - URL, settingKey, or FileReference
527
+ * @param {object} options - { volume, loop, playbackRate, startTime }
528
+ */
529
+ play: function (source, options) {
530
+ var payload = { command: 'play', options: options || {} }
531
+
532
+ if (typeof source === 'string') {
533
+ if (source.startsWith('http') || source.startsWith('data:') || source.startsWith('blob:')) {
534
+ payload.url = source
535
+ } else {
536
+ // Assume it's a settingKey
537
+ payload.settingKey = source
538
+ }
539
+ } else if (source && source.__type === 'file-reference') {
540
+ payload.fileRef = source
541
+ } else if (source && source.settingKey) {
542
+ payload.settingKey = source.settingKey
543
+ }
544
+
545
+ send('AUDIO_COMMAND', payload)
546
+ },
547
+
548
+ /** Pause audio playback */
549
+ pause: function () {
550
+ send('AUDIO_COMMAND', { command: 'pause' })
551
+ },
552
+
553
+ /** Resume audio playback */
554
+ resume: function () {
555
+ send('AUDIO_COMMAND', { command: 'resume' })
556
+ },
557
+
558
+ /** Stop and reset audio */
559
+ stop: function () {
560
+ send('AUDIO_COMMAND', { command: 'stop' })
561
+ },
562
+
563
+ /**
564
+ * Seek to position
565
+ * @param {number} seconds - Position in seconds
566
+ */
567
+ seek: function (seconds) {
568
+ send('AUDIO_COMMAND', { command: 'seek', value: seconds })
569
+ },
570
+
571
+ /**
572
+ * Set volume
573
+ * @param {number} volume - Volume from 0.0 to 1.0
574
+ */
575
+ setVolume: function (volume) {
576
+ send('AUDIO_COMMAND', { command: 'setVolume', value: Math.max(0, Math.min(1, volume)) })
577
+ },
578
+
579
+ /**
580
+ * Enable or disable loop
581
+ * @param {boolean} loop - Whether to loop
582
+ */
583
+ setLoop: function (loop) {
584
+ send('AUDIO_COMMAND', { command: 'setLoop', value: !!loop })
585
+ },
586
+
587
+ /**
588
+ * Set playback rate
589
+ * @param {number} rate - Playback rate (0.25 to 4.0)
590
+ */
591
+ setPlaybackRate: function (rate) {
592
+ send('AUDIO_COMMAND', { command: 'setPlaybackRate', value: rate })
593
+ },
594
+
595
+ /**
596
+ * Get current audio state
597
+ * @returns {object} { status, currentTime, duration, volume, loop, playbackRate, error? }
598
+ */
599
+ getState: function () {
600
+ return {
601
+ status: audioState.status,
602
+ currentTime: audioState.currentTime,
603
+ duration: audioState.duration,
604
+ volume: audioState.volume,
605
+ loop: audioState.loop,
606
+ playbackRate: audioState.playbackRate,
607
+ error: audioState.error
608
+ }
609
+ },
610
+
611
+ /**
612
+ * Listen to audio state changes
613
+ * @param {function} callback - Called with AudioState on each change
614
+ * @returns {function} Unsubscribe function
615
+ */
616
+ onStateChange: function (callback) {
617
+ if (typeof callback !== 'function') return function () { }
618
+ audioCallbacks.add(callback)
619
+ // Call immediately with current state
620
+ callback(audioState)
621
+ return function () { audioCallbacks.delete(callback) }
622
+ }
623
+ }
624
+ }
625
+
626
+ console.warn('[MyWallpaper] SDK v2.5 ready:', layerId)
627
+ })()