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

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 +46 -101
  16. package/ios/TrackPlayerBridge.mm +2 -0
  17. package/ios/player/AVPlayerEngine.swift +46 -32
  18. package/ios/player/AudioCache.swift +34 -0
  19. package/ios/player/AudioPlayer.swift +36 -21
  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 +19 -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 +22 -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] {
@@ -152,6 +156,28 @@ class TrackPlayer: RCTEventEmitter {
152
156
  audioCache?.removeAll()
153
157
  }
154
158
 
159
+ @objc(preload:duration:)
160
+ func preload(item: [String: Any], duration: Double) {
161
+ guard let urlString = extractUrl(from: item),
162
+ let url = URL(string: urlString) else { return }
163
+
164
+ let headers = item["headers"] as? [String: String]
165
+ player.preloader?.preload(url: url, headers: headers)
166
+ }
167
+
168
+ @objc(cancelPreload:)
169
+ func cancelPreload(item: [String: Any]) {
170
+ guard let urlString = extractUrl(from: item),
171
+ let url = URL(string: urlString) else { return }
172
+ player.preloader?.cancel(url: url)
173
+ }
174
+
175
+ private func extractUrl(from item: [String: Any]) -> String? {
176
+ if let url = item["url"] as? String { return url }
177
+ if let urlObj = item["url"] as? [String: Any], let uri = urlObj["uri"] as? String { return uri }
178
+ return nil
179
+ }
180
+
155
181
  @objc(setPlaybackSpeed:)
156
182
  func setPlaybackSpeed(speed: Double) {
157
183
  player.rate = Float(speed)
@@ -233,8 +259,8 @@ class TrackPlayer: RCTEventEmitter {
233
259
  @objc(clear)
234
260
  func clear() {
235
261
  player.clear()
236
- if sleepTimerType == "mediaItem" {
237
- cancelSleepTimerInternal(restoreVolume: false)
262
+ if sleepTimerController?.sleepTimerType == "mediaItem" {
263
+ sleepTimerController?.cancelInternal(restoreVolume: false)
238
264
  }
239
265
  emitEvent(event: QueueChangedEvent())
240
266
  }
@@ -413,101 +439,24 @@ class TrackPlayer: RCTEventEmitter {
413
439
 
414
440
  // MARK: - Sleep Timer
415
441
 
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
442
  @objc(sleepAfterTime:fadeOutSeconds:)
467
443
  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()
444
+ sleepTimerController?.sleepAfterTime(seconds: seconds, fadeOutSeconds: fadeOutSeconds)
481
445
  }
482
446
 
483
447
  @objc(sleepAfterMediaItemAtIndex:)
484
448
  func sleepAfterMediaItemAtIndex(index: Double) {
485
- cancelSleepTimerInternal(restoreVolume: true)
486
- sleepTimerType = "mediaItem"
487
- sleepTimerTargetIndex = Int(index)
488
- sleepTimerPreviousIndex = player.currentIndex
449
+ sleepTimerController?.sleepAfterMediaItemAtIndex(index: Int(index))
489
450
  }
490
451
 
491
452
  @objc(getSleepTimer)
492
453
  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
- }
454
+ return sleepTimerController?.getState()
506
455
  }
507
456
 
508
457
  @objc(cancelSleepTimer)
509
458
  func cancelSleepTimer() {
510
- cancelSleepTimerInternal(restoreVolume: true)
459
+ sleepTimerController?.cancel()
511
460
  }
512
461
 
513
462
  // MARK: - Browse Tree
@@ -522,7 +471,8 @@ class TrackPlayer: RCTEventEmitter {
522
471
  @objc(destroy)
523
472
  func destroy() {
524
473
  BrowseTreeStore.shared.clear()
525
- cancelSleepTimerInternal(restoreVolume: false)
474
+ sleepTimerController?.cancel()
475
+ sleepTimerController = nil
526
476
  stopProgressSyncTimer(fireFinalTick: false)
527
477
  let commandCenter = MPRemoteCommandCenter.shared()
528
478
  commandCenter.togglePlayPauseCommand.removeTarget(nil)
@@ -580,17 +530,12 @@ class TrackPlayer: RCTEventEmitter {
580
530
 
581
531
  func handleCurrentItemChanged(item: AudioItem?, index: Int) {
582
532
  // 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
- }
533
+ if sleepTimerController?.handleItemTransition(to: index) == true {
534
+ // Defer pause to next run loop calling during transition gets overridden
535
+ DispatchQueue.main.async { [weak self] in
536
+ self?.player.pause()
591
537
  }
592
538
  }
593
- sleepTimerPreviousIndex = index
594
539
  let mediaItem = item as? MediaItem
595
540
  let dict = mediaItem?.toDictionary()
596
541
  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