@rntp/player 5.0.0-beta.4 → 5.0.0-beta.6

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 (56) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +99 -87
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
  6. package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
  7. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
  13. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
  14. package/android/src/test/resources/robolectric.properties +1 -0
  15. package/ios/TrackPlayer.swift +47 -101
  16. package/ios/TrackPlayerBridge.mm +2 -0
  17. package/ios/player/AVPlayerEngine.swift +47 -35
  18. package/ios/player/AudioCache.swift +34 -0
  19. package/ios/player/AudioPlayer.swift +70 -22
  20. package/ios/player/CacheProxyServer.swift +429 -0
  21. package/ios/player/DownloadCoordinator.swift +242 -0
  22. package/ios/player/Preloader.swift +21 -90
  23. package/ios/player/SleepTimerController.swift +147 -0
  24. package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
  25. package/ios/tests/AudioPlayerTests.swift +6 -0
  26. package/ios/tests/CacheProxyServerTests.swift +403 -0
  27. package/ios/tests/DownloadCoordinatorTests.swift +197 -0
  28. package/ios/tests/LocalAudioServer.swift +171 -0
  29. package/ios/tests/MockPlayerEngine.swift +1 -0
  30. package/ios/tests/QueueManagerTests.swift +6 -0
  31. package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
  32. package/ios/tests/SleepTimerTests.swift +70 -0
  33. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  34. package/lib/commonjs/audio.js +19 -0
  35. package/lib/commonjs/audio.js.map +1 -1
  36. package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
  37. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  38. package/lib/module/NativeTrackPlayer.js.map +1 -1
  39. package/lib/module/audio.js +17 -0
  40. package/lib/module/audio.js.map +1 -1
  41. package/lib/module/interfaces/PlayerConfig.js +1 -1
  42. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  43. package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
  44. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  45. package/lib/typescript/src/audio.d.ts +12 -1
  46. package/lib/typescript/src/audio.d.ts.map +1 -1
  47. package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
  48. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +21 -2
  50. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  51. package/package.json +4 -1
  52. package/src/NativeTrackPlayer.ts +4 -0
  53. package/src/audio.ts +18 -0
  54. package/src/interfaces/MediaItem.ts +4 -1
  55. package/src/interfaces/PlayerConfig.ts +24 -3
  56. package/ios/player/CachingResourceLoader.swift +0 -273
@@ -0,0 +1,380 @@
1
+ package com.doublesymmetry.trackplayer.models
2
+
3
+ import androidx.media3.common.MediaItem
4
+ import androidx.media3.common.MediaMetadata
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.JavaOnlyMap
7
+ import com.facebook.react.bridge.ReadableMap
8
+ import com.facebook.react.bridge.ReadableMapKeySetIterator
9
+ import com.facebook.react.bridge.ReadableType
10
+ import io.mockk.every
11
+ import io.mockk.mockk
12
+ import io.mockk.mockkStatic
13
+ import io.mockk.unmockkStatic
14
+ import org.junit.After
15
+ import org.junit.Before
16
+ import org.junit.Test
17
+ import org.junit.Assert.*
18
+ import org.junit.runner.RunWith
19
+ import org.robolectric.RobolectricTestRunner
20
+
21
+ @RunWith(RobolectricTestRunner::class)
22
+ class TrackPlayerMediaItemTest {
23
+
24
+ @Before
25
+ fun setUp() {
26
+ // Arguments.createMap() and createArray() call into native code; replace with JavaOnlyMap.
27
+ mockkStatic(Arguments::class)
28
+ every { Arguments.createMap() } answers { JavaOnlyMap() }
29
+ }
30
+
31
+ @After
32
+ fun tearDown() {
33
+ unmockkStatic(Arguments::class)
34
+ }
35
+
36
+ // ---- fromReadableMap ----
37
+
38
+ @Test
39
+ fun `fromReadableMap parses basic fields`() {
40
+ val map = mockk<ReadableMap>(relaxed = true) {
41
+ every { hasKey("url") } returns true
42
+ every { getType("url") } returns mockk { every { name } returns "String" }
43
+ every { getString("url") } returns "https://example.com/track.mp3"
44
+ every { hasKey("mediaId") } returns true
45
+ every { getString("mediaId") } returns "track-123"
46
+ every { hasKey("title") } returns true
47
+ every { getString("title") } returns "My Track"
48
+ every { hasKey("artist") } returns true
49
+ every { getString("artist") } returns "My Artist"
50
+ every { hasKey("albumTitle") } returns true
51
+ every { getString("albumTitle") } returns "My Album"
52
+ every { hasKey("artworkUrl") } returns true
53
+ every { getString("artworkUrl") } returns "https://example.com/art.jpg"
54
+ every { hasKey("duration") } returns true
55
+ every { getDouble("duration") } returns 180.5
56
+ every { hasKey("isLive") } returns true
57
+ every { getBoolean("isLive") } returns false
58
+ every { hasKey("mimeType") } returns false
59
+ }
60
+
61
+ val item = TrackPlayerMediaItem.fromReadableMap(map)
62
+
63
+ assertEquals("track-123", item.mediaId)
64
+ assertEquals("https://example.com/track.mp3", item.url)
65
+ assertEquals("My Track", item.title)
66
+ assertEquals("My Artist", item.artist)
67
+ assertEquals("My Album", item.albumTitle)
68
+ assertEquals("https://example.com/art.jpg", item.artworkUrl)
69
+ assertEquals(180.5, item.duration!!, 0.001)
70
+ assertEquals(false, item.isLive)
71
+ assertNull(item.mimeType)
72
+ assertNull(item.headers)
73
+ }
74
+
75
+ @Test
76
+ fun `fromReadableMap with missing optional fields uses null`() {
77
+ val map = mockk<ReadableMap>(relaxed = true) {
78
+ every { hasKey("url") } returns true
79
+ every { getType("url") } returns mockk { every { name } returns "String" }
80
+ every { getString("url") } returns "https://example.com/track.mp3"
81
+ every { hasKey("mediaId") } returns false
82
+ every { hasKey("title") } returns false
83
+ every { hasKey("artist") } returns false
84
+ every { hasKey("albumTitle") } returns false
85
+ every { hasKey("artworkUrl") } returns false
86
+ every { hasKey("duration") } returns false
87
+ every { hasKey("isLive") } returns false
88
+ every { hasKey("mimeType") } returns false
89
+ }
90
+
91
+ val item = TrackPlayerMediaItem.fromReadableMap(map)
92
+
93
+ assertNull(item.mediaId)
94
+ assertEquals("https://example.com/track.mp3", item.url)
95
+ assertNull(item.title)
96
+ assertNull(item.artist)
97
+ assertNull(item.albumTitle)
98
+ assertNull(item.artworkUrl)
99
+ assertNull(item.duration)
100
+ assertNull(item.isLive)
101
+ assertNull(item.headers)
102
+ }
103
+
104
+ @Test
105
+ fun `fromReadableMap with url as object parses uri and headers`() {
106
+ val headersIterator = mockk<ReadableMapKeySetIterator> {
107
+ every { hasNextKey() } returnsMany listOf(true, true, false)
108
+ every { nextKey() } returnsMany listOf("Authorization", "X-Custom")
109
+ }
110
+ val headersMap = mockk<ReadableMap>(relaxed = true) {
111
+ every { keySetIterator() } returns headersIterator
112
+ every { getString("Authorization") } returns "Bearer token123"
113
+ every { getString("X-Custom") } returns "custom-value"
114
+ }
115
+ val urlMap = mockk<ReadableMap>(relaxed = true) {
116
+ every { getString("uri") } returns "https://example.com/secure.mp3"
117
+ every { getString("url") } returns null
118
+ every { getMap("headers") } returns headersMap
119
+ }
120
+
121
+ val map = mockk<ReadableMap>(relaxed = true) {
122
+ every { hasKey("url") } returns true
123
+ every { getType("url") } returns mockk { every { name } returns "Map" }
124
+ every { getMap("url") } returns urlMap
125
+ every { hasKey("mediaId") } returns false
126
+ every { hasKey("title") } returns false
127
+ every { hasKey("artist") } returns false
128
+ every { hasKey("albumTitle") } returns false
129
+ every { hasKey("artworkUrl") } returns false
130
+ every { hasKey("duration") } returns false
131
+ every { hasKey("isLive") } returns false
132
+ every { hasKey("mimeType") } returns false
133
+ }
134
+
135
+ val item = TrackPlayerMediaItem.fromReadableMap(map)
136
+
137
+ assertEquals("https://example.com/secure.mp3", item.url)
138
+ assertNotNull(item.headers)
139
+ assertEquals("Bearer token123", item.headers!!["Authorization"])
140
+ assertEquals("custom-value", item.headers!!["X-Custom"])
141
+ }
142
+
143
+ @Test
144
+ fun `fromReadableMap with url object falls back to url key when uri absent`() {
145
+ val urlMap = mockk<ReadableMap>(relaxed = true) {
146
+ every { getString("uri") } returns null
147
+ every { getString("url") } returns "https://example.com/fallback.mp3"
148
+ every { getMap("headers") } returns null
149
+ }
150
+
151
+ val map = mockk<ReadableMap>(relaxed = true) {
152
+ every { hasKey("url") } returns true
153
+ every { getType("url") } returns mockk { every { name } returns "Map" }
154
+ every { getMap("url") } returns urlMap
155
+ every { hasKey("mediaId") } returns false
156
+ every { hasKey("title") } returns false
157
+ every { hasKey("artist") } returns false
158
+ every { hasKey("albumTitle") } returns false
159
+ every { hasKey("artworkUrl") } returns false
160
+ every { hasKey("duration") } returns false
161
+ every { hasKey("isLive") } returns false
162
+ every { hasKey("mimeType") } returns false
163
+ }
164
+
165
+ val item = TrackPlayerMediaItem.fromReadableMap(map)
166
+ assertEquals("https://example.com/fallback.mp3", item.url)
167
+ }
168
+
169
+ // ---- asMediaItem ----
170
+
171
+ @Test
172
+ fun `asMediaItem produces correct Media3 MediaItem`() {
173
+ val item = TrackPlayerMediaItem(
174
+ mediaId = "track-abc",
175
+ url = "https://example.com/track.mp3",
176
+ title = "Test Track",
177
+ artist = "Test Artist",
178
+ albumTitle = "Test Album",
179
+ artworkUrl = "https://example.com/art.jpg",
180
+ )
181
+
182
+ val mediaItem = item.asMediaItem()
183
+
184
+ assertEquals("track-abc", mediaItem.mediaId)
185
+ assertNotNull(mediaItem.localConfiguration)
186
+ assertEquals("https://example.com/track.mp3", mediaItem.localConfiguration!!.uri.toString())
187
+ assertEquals("Test Track", mediaItem.mediaMetadata.title.toString())
188
+ assertEquals("Test Artist", mediaItem.mediaMetadata.artist.toString())
189
+ assertEquals("Test Album", mediaItem.mediaMetadata.albumTitle.toString())
190
+ assertEquals("https://example.com/art.jpg", mediaItem.mediaMetadata.artworkUri.toString())
191
+ }
192
+
193
+ @Test
194
+ fun `asMediaItem uses url as mediaId when mediaId is null`() {
195
+ val item = TrackPlayerMediaItem(
196
+ mediaId = null,
197
+ url = "https://example.com/track.mp3",
198
+ )
199
+
200
+ val mediaItem = item.asMediaItem()
201
+ assertEquals("https://example.com/track.mp3", mediaItem.mediaId)
202
+ }
203
+
204
+ @Test
205
+ fun `asMediaItem stores duration and isLive in extras`() {
206
+ val item = TrackPlayerMediaItem(
207
+ url = "https://example.com/track.mp3",
208
+ duration = 240.0,
209
+ isLive = true,
210
+ )
211
+
212
+ val mediaItem = item.asMediaItem()
213
+ val extras = mediaItem.mediaMetadata.extras
214
+
215
+ assertNotNull(extras)
216
+ assertEquals(240.0, extras!!.getDouble("duration"), 0.001)
217
+ assertTrue(extras.getBoolean("isLive"))
218
+ assertNotNull(mediaItem.liveConfiguration)
219
+ }
220
+
221
+ @Test
222
+ fun `asMediaItem with headers stores headers in request extras`() {
223
+ val item = TrackPlayerMediaItem(
224
+ url = "https://example.com/track.mp3",
225
+ headers = mapOf("Authorization" to "Bearer xyz", "Accept" to "audio/*"),
226
+ )
227
+
228
+ val mediaItem = item.asMediaItem()
229
+ val requestExtras = mediaItem.requestMetadata.extras
230
+ assertNotNull(requestExtras)
231
+ val headerBundle = requestExtras!!.getBundle("headers")
232
+ assertNotNull(headerBundle)
233
+ assertEquals("Bearer xyz", headerBundle!!.getString("Authorization"))
234
+ assertEquals("audio/*", headerBundle.getString("Accept"))
235
+ }
236
+
237
+ @Test
238
+ fun `asMediaItem sets mediaUri in requestMetadata for http urls`() {
239
+ val item = TrackPlayerMediaItem(
240
+ url = "https://example.com/track.mp3",
241
+ )
242
+
243
+ val mediaItem = item.asMediaItem()
244
+ // HTTP URIs should be set as mediaUri for Cast support
245
+ assertNotNull(mediaItem.requestMetadata.mediaUri)
246
+ assertEquals("https://example.com/track.mp3", mediaItem.requestMetadata.mediaUri.toString())
247
+ }
248
+
249
+ @Test
250
+ fun `asMediaItem does not set mediaUri for file urls`() {
251
+ val item = TrackPlayerMediaItem(
252
+ url = "file:///storage/music/local.mp3",
253
+ )
254
+
255
+ val mediaItem = item.asMediaItem()
256
+ // Local file URIs should not be set as mediaUri (inaccessible from Chromecast)
257
+ assertNull(mediaItem.requestMetadata.mediaUri)
258
+ }
259
+
260
+ @Test
261
+ fun `asMediaItem sets mimeType when provided`() {
262
+ val item = TrackPlayerMediaItem(
263
+ url = "https://example.com/track.mp3",
264
+ mimeType = "audio/mpeg",
265
+ )
266
+
267
+ val mediaItem = item.asMediaItem()
268
+ assertEquals("audio/mpeg", mediaItem.localConfiguration?.mimeType)
269
+ }
270
+
271
+ // ---- toWritableMap ----
272
+
273
+ @Test
274
+ fun `toWritableMap includes all fields`() {
275
+ val item = TrackPlayerMediaItem(
276
+ mediaId = "t1",
277
+ url = "https://example.com/track.mp3",
278
+ title = "Track",
279
+ artist = "Artist",
280
+ albumTitle = "Album",
281
+ artworkUrl = "https://example.com/art.jpg",
282
+ duration = 120.0,
283
+ isLive = false,
284
+ )
285
+
286
+ val map = item.toWritableMap()
287
+ // WritableMap from Arguments.createMap() — verify no exception thrown and key existence
288
+ // (JavaOnlyMap in Robolectric supports getString/getDouble/getBoolean)
289
+ assertEquals("t1", map.getString("mediaId"))
290
+ assertEquals("https://example.com/track.mp3", map.getString("url"))
291
+ assertEquals("Track", map.getString("title"))
292
+ assertEquals("Artist", map.getString("artist"))
293
+ assertEquals("Album", map.getString("albumTitle"))
294
+ assertEquals("https://example.com/art.jpg", map.getString("artworkUrl"))
295
+ assertEquals(120.0, map.getDouble("duration"), 0.001)
296
+ assertFalse(map.getBoolean("isLive"))
297
+ }
298
+
299
+ @Test
300
+ fun `toWritableMap uses url as mediaId when mediaId is null`() {
301
+ val item = TrackPlayerMediaItem(
302
+ mediaId = null,
303
+ url = "https://example.com/track.mp3",
304
+ )
305
+
306
+ val map = item.toWritableMap()
307
+ assertEquals("https://example.com/track.mp3", map.getString("mediaId"))
308
+ }
309
+
310
+ @Test
311
+ fun `toWritableMap omits null optional fields`() {
312
+ val item = TrackPlayerMediaItem(
313
+ url = "https://example.com/track.mp3",
314
+ )
315
+
316
+ val map = item.toWritableMap()
317
+ assertFalse(map.hasKey("title"))
318
+ assertFalse(map.hasKey("artist"))
319
+ assertFalse(map.hasKey("albumTitle"))
320
+ assertFalse(map.hasKey("artworkUrl"))
321
+ assertFalse(map.hasKey("duration"))
322
+ assertFalse(map.hasKey("isLive"))
323
+ }
324
+
325
+ // ---- fromMediaItem round-trip ----
326
+
327
+ @Test
328
+ fun `fromMediaItem round-trips correctly`() {
329
+ val original = TrackPlayerMediaItem(
330
+ mediaId = "round-trip-id",
331
+ url = "https://example.com/track.mp3",
332
+ title = "Round Trip",
333
+ artist = "Artist",
334
+ albumTitle = "Album",
335
+ artworkUrl = "https://example.com/art.jpg",
336
+ duration = 90.0,
337
+ )
338
+
339
+ val mediaItem = original.asMediaItem()
340
+ val restored = TrackPlayerMediaItem.fromMediaItem(mediaItem)
341
+
342
+ assertEquals("round-trip-id", restored.mediaId)
343
+ assertEquals("https://example.com/track.mp3", restored.url)
344
+ assertEquals("Round Trip", restored.title)
345
+ assertEquals("Artist", restored.artist)
346
+ assertEquals("Album", restored.albumTitle)
347
+ assertEquals("https://example.com/art.jpg", restored.artworkUrl)
348
+ assertEquals(90.0, restored.duration!!, 0.001)
349
+ assertNull(restored.isLive) // not set so extras won't contain "isLive"
350
+ }
351
+
352
+ @Test
353
+ fun `fromMediaItem preserves isLive true`() {
354
+ val original = TrackPlayerMediaItem(
355
+ url = "https://example.com/live.mp3",
356
+ isLive = true,
357
+ )
358
+
359
+ val mediaItem = original.asMediaItem()
360
+ val restored = TrackPlayerMediaItem.fromMediaItem(mediaItem)
361
+
362
+ assertEquals(true, restored.isLive)
363
+ }
364
+
365
+ @Test
366
+ fun `fromMediaItem with no localConfiguration falls back to mediaId as url`() {
367
+ // Build a Media3 MediaItem without a URI (e.g., cast-only item)
368
+ val mediaItem = MediaItem.Builder()
369
+ .setMediaId("cast-item-id")
370
+ .setMediaMetadata(MediaMetadata.Builder().setTitle("Cast Track").build())
371
+ .build()
372
+
373
+ val item = TrackPlayerMediaItem.fromMediaItem(mediaItem)
374
+
375
+ assertEquals("cast-item-id", item.mediaId)
376
+ // Falls back to mediaId when no localConfiguration
377
+ assertEquals("cast-item-id", item.url)
378
+ assertEquals("Cast Track", item.title)
379
+ }
380
+ }
@@ -22,13 +22,7 @@ class TrackPlayer: RCTEventEmitter {
22
22
  private var progressSyncHttpUrl: String?
23
23
  private var progressSyncHttpHeaders: [String: String]?
24
24
 
25
- private var sleepTimerType: String? // "time" or "mediaItem"
26
- private var sleepTimerRemainingSeconds: Double = 0
27
- private var sleepTimerFadeOutSeconds: Double = 0
28
- private var sleepTimerTargetIndex: Int?
29
- private var sleepTimer: Timer?
30
- private var sleepTimerPreFadeVolume: Float?
31
- private var sleepTimerPreviousIndex: Int?
25
+ private var sleepTimerController: SleepTimerController?
32
26
 
33
27
  // MARK: - Initializers
34
28
 
@@ -71,19 +65,29 @@ class TrackPlayer: RCTEventEmitter {
71
65
  try? AVAudioSession.sharedInstance().setCategory(.playback, mode: mode)
72
66
 
73
67
  var cache: AudioCache? = nil
68
+ var preloadWindow = 0
74
69
  if let cacheConfig = config["cache"] as? [String: Any] {
75
- let maxSize = (cacheConfig["maxSizeBytes"] as? NSNumber)?.int64Value ?? (100 * 1024 * 1024)
70
+ let maxSize = (cacheConfig["maxSizeBytes"] as? NSNumber)?.int64Value ?? (500 * 1024 * 1024)
76
71
  cache = AudioCache(maxSizeBytes: maxSize)
72
+ if let preloadConfig = cacheConfig["preloading"] as? [String: Any] {
73
+ preloadWindow = (preloadConfig["window"] as? NSNumber)?.intValue ?? 0
74
+ }
77
75
  }
78
76
  audioCache = cache
79
77
 
80
78
  let handleNoisy = config["handleAudioBecomingNoisy"] as? Bool ?? true
81
79
  player = AudioPlayer(handleAudioBecomingNoisy: handleNoisy, cache: cache)
80
+ player.preloadWindow = preloadWindow
82
81
  bindPlayerCallbacks()
83
82
  BrowseTreeStore.shared.player = player
84
83
  lastEmittedStateString = nil
85
84
  lastIsPlaying = false
86
85
 
86
+ sleepTimerController = SleepTimerController(player: player)
87
+ sleepTimerController?.onTriggered = { [weak self] type in
88
+ self?.emitEvent(event: SleepTimerTriggeredEvent(sleepType: type))
89
+ }
90
+
87
91
  if let progressSync = config["progressSync"] as? [String: Any] {
88
92
  progressSyncIntervalSeconds = progressSync["intervalSeconds"] as? Double ?? 0
89
93
  if let http = progressSync["http"] as? [String: Any] {
@@ -149,9 +153,32 @@ class TrackPlayer: RCTEventEmitter {
149
153
 
150
154
  @objc(clearCache)
151
155
  func clearCache() {
156
+ player.cancelAllDownloads()
152
157
  audioCache?.removeAll()
153
158
  }
154
159
 
160
+ @objc(preload:duration:)
161
+ func preload(item: [String: Any], duration: Double) {
162
+ guard let urlString = extractUrl(from: item),
163
+ let url = URL(string: urlString) else { return }
164
+
165
+ let headers = item["headers"] as? [String: String]
166
+ player.preloader?.preload(url: url, headers: headers)
167
+ }
168
+
169
+ @objc(cancelPreload:)
170
+ func cancelPreload(item: [String: Any]) {
171
+ guard let urlString = extractUrl(from: item),
172
+ let url = URL(string: urlString) else { return }
173
+ player.preloader?.cancel(url: url)
174
+ }
175
+
176
+ private func extractUrl(from item: [String: Any]) -> String? {
177
+ if let url = item["url"] as? String { return url }
178
+ if let urlObj = item["url"] as? [String: Any], let uri = urlObj["uri"] as? String { return uri }
179
+ return nil
180
+ }
181
+
155
182
  @objc(setPlaybackSpeed:)
156
183
  func setPlaybackSpeed(speed: Double) {
157
184
  player.rate = Float(speed)
@@ -233,8 +260,8 @@ class TrackPlayer: RCTEventEmitter {
233
260
  @objc(clear)
234
261
  func clear() {
235
262
  player.clear()
236
- if sleepTimerType == "mediaItem" {
237
- cancelSleepTimerInternal(restoreVolume: false)
263
+ if sleepTimerController?.sleepTimerType == "mediaItem" {
264
+ sleepTimerController?.cancelInternal(restoreVolume: false)
238
265
  }
239
266
  emitEvent(event: QueueChangedEvent())
240
267
  }
@@ -413,101 +440,24 @@ class TrackPlayer: RCTEventEmitter {
413
440
 
414
441
  // MARK: - Sleep Timer
415
442
 
416
- private func cancelSleepTimerInternal(restoreVolume: Bool) {
417
- sleepTimer?.invalidate()
418
- sleepTimer = nil
419
- if restoreVolume, let preFadeVolume = sleepTimerPreFadeVolume {
420
- player.volume = preFadeVolume
421
- }
422
- sleepTimerPreFadeVolume = nil
423
- sleepTimerType = nil
424
- sleepTimerRemainingSeconds = 0
425
- sleepTimerFadeOutSeconds = 0
426
- sleepTimerTargetIndex = nil
427
- sleepTimerPreviousIndex = nil
428
- }
429
-
430
- private func onSleepTimerTick() {
431
- sleepTimerRemainingSeconds -= 1
432
-
433
- // Handle fade-out (before zero-check so final tick sets volume to 0)
434
- if sleepTimerFadeOutSeconds > 0 && sleepTimerRemainingSeconds < sleepTimerFadeOutSeconds {
435
- if sleepTimerPreFadeVolume == nil {
436
- sleepTimerPreFadeVolume = player.volume
437
- }
438
- let progress = max(0, sleepTimerRemainingSeconds) / sleepTimerFadeOutSeconds
439
- player.volume = (sleepTimerPreFadeVolume ?? 1.0) * Float(progress)
440
- }
441
-
442
- if sleepTimerRemainingSeconds <= 0 {
443
- sleepTimerRemainingSeconds = 0
444
- player.pause()
445
- emitEvent(event: SleepTimerTriggeredEvent(sleepType: "time"))
446
- // Restore volume after pausing so next playback isn't muted
447
- cancelSleepTimerInternal(restoreVolume: true)
448
- return
449
- }
450
- }
451
-
452
- private func startSleepCountdownTimer() {
453
- sleepTimer?.invalidate()
454
- let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
455
- self?.onSleepTimerTick()
456
- }
457
- RunLoop.main.add(timer, forMode: .common)
458
- sleepTimer = timer
459
- }
460
-
461
- private func pauseSleepCountdownTimer() {
462
- sleepTimer?.invalidate()
463
- sleepTimer = nil
464
- }
465
-
466
443
  @objc(sleepAfterTime:fadeOutSeconds:)
467
444
  func sleepAfterTime(seconds: Double, fadeOutSeconds: Double) {
468
- cancelSleepTimerInternal(restoreVolume: true)
469
- sleepTimerType = "time"
470
- sleepTimerRemainingSeconds = seconds
471
- sleepTimerFadeOutSeconds = min(fadeOutSeconds, seconds)
472
-
473
- if seconds <= 0 {
474
- player.pause()
475
- emitEvent(event: SleepTimerTriggeredEvent(sleepType: "time"))
476
- cancelSleepTimerInternal(restoreVolume: true)
477
- return
478
- }
479
-
480
- startSleepCountdownTimer()
445
+ sleepTimerController?.sleepAfterTime(seconds: seconds, fadeOutSeconds: fadeOutSeconds)
481
446
  }
482
447
 
483
448
  @objc(sleepAfterMediaItemAtIndex:)
484
449
  func sleepAfterMediaItemAtIndex(index: Double) {
485
- cancelSleepTimerInternal(restoreVolume: true)
486
- sleepTimerType = "mediaItem"
487
- sleepTimerTargetIndex = Int(index)
488
- sleepTimerPreviousIndex = player.currentIndex
450
+ sleepTimerController?.sleepAfterMediaItemAtIndex(index: Int(index))
489
451
  }
490
452
 
491
453
  @objc(getSleepTimer)
492
454
  func getSleepTimer() -> [String: Any]? {
493
- guard let type = sleepTimerType else { return nil }
494
- if type == "time" {
495
- return [
496
- "type": "time",
497
- "remainingSeconds": sleepTimerRemainingSeconds,
498
- "fadeOutSeconds": sleepTimerFadeOutSeconds
499
- ]
500
- } else {
501
- return [
502
- "type": "mediaItem",
503
- "index": sleepTimerTargetIndex ?? 0
504
- ]
505
- }
455
+ return sleepTimerController?.getState()
506
456
  }
507
457
 
508
458
  @objc(cancelSleepTimer)
509
459
  func cancelSleepTimer() {
510
- cancelSleepTimerInternal(restoreVolume: true)
460
+ sleepTimerController?.cancel()
511
461
  }
512
462
 
513
463
  // MARK: - Browse Tree
@@ -522,7 +472,8 @@ class TrackPlayer: RCTEventEmitter {
522
472
  @objc(destroy)
523
473
  func destroy() {
524
474
  BrowseTreeStore.shared.clear()
525
- cancelSleepTimerInternal(restoreVolume: false)
475
+ sleepTimerController?.cancel()
476
+ sleepTimerController = nil
526
477
  stopProgressSyncTimer(fireFinalTick: false)
527
478
  let commandCenter = MPRemoteCommandCenter.shared()
528
479
  commandCenter.togglePlayPauseCommand.removeTarget(nil)
@@ -580,17 +531,12 @@ class TrackPlayer: RCTEventEmitter {
580
531
 
581
532
  func handleCurrentItemChanged(item: AudioItem?, index: Int) {
582
533
  // Sleep timer: check if the target item just finished
583
- if sleepTimerType == "mediaItem", let targetIndex = sleepTimerTargetIndex {
584
- if sleepTimerPreviousIndex == targetIndex && index != targetIndex {
585
- emitEvent(event: SleepTimerTriggeredEvent(sleepType: "mediaItem"))
586
- cancelSleepTimerInternal(restoreVolume: false)
587
- // Defer pause to next run loop — calling during transition gets overridden
588
- DispatchQueue.main.async { [weak self] in
589
- self?.player.pause()
590
- }
534
+ if sleepTimerController?.handleItemTransition(to: index) == true {
535
+ // Defer pause to next run loop calling during transition gets overridden
536
+ DispatchQueue.main.async { [weak self] in
537
+ self?.player.pause()
591
538
  }
592
539
  }
593
- sleepTimerPreviousIndex = index
594
540
  let mediaItem = item as? MediaItem
595
541
  let dict = mediaItem?.toDictionary()
596
542
  BrowseTreeStore.shared.updateNowPlaying(mediaId: mediaItem?.mediaId)
@@ -29,6 +29,8 @@ RCT_EXTERN_METHOD(skipToPrevious);
29
29
  RCT_EXTERN_METHOD(skipToIndex:(double)index);
30
30
  RCT_EXTERN_METHOD(retry);
31
31
  RCT_EXTERN_METHOD(clearCache);
32
+ RCT_EXTERN_METHOD(preload:(NSDictionary *)item duration:(double)duration);
33
+ RCT_EXTERN_METHOD(cancelPreload:(NSDictionary *)item);
32
34
  RCT_EXTERN_METHOD(setPlaybackSpeed:(double)speed);
33
35
  RCT_EXTERN_METHOD(setVolume:(double)volume);
34
36