@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.
- package/android/build.gradle +7 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +99 -87
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
- package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
- package/android/src/test/resources/robolectric.properties +1 -0
- package/ios/TrackPlayer.swift +47 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +47 -35
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +70 -22
- package/ios/player/CacheProxyServer.swift +429 -0
- package/ios/player/DownloadCoordinator.swift +242 -0
- package/ios/player/Preloader.swift +21 -90
- package/ios/player/SleepTimerController.swift +147 -0
- package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
- package/ios/tests/AudioPlayerTests.swift +6 -0
- package/ios/tests/CacheProxyServerTests.swift +403 -0
- package/ios/tests/DownloadCoordinatorTests.swift +197 -0
- package/ios/tests/LocalAudioServer.swift +171 -0
- package/ios/tests/MockPlayerEngine.swift +1 -0
- package/ios/tests/QueueManagerTests.swift +6 -0
- package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
- package/ios/tests/SleepTimerTests.swift +70 -0
- package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
- package/lib/commonjs/audio.js +19 -0
- package/lib/commonjs/audio.js.map +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
- package/lib/module/NativeTrackPlayer.js.map +1 -1
- package/lib/module/audio.js +17 -0
- package/lib/module/audio.js.map +1 -1
- package/lib/module/interfaces/PlayerConfig.js +1 -1
- package/lib/module/interfaces/PlayerConfig.js.map +1 -1
- package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
- package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
- package/lib/typescript/src/audio.d.ts +12 -1
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts +21 -2
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/NativeTrackPlayer.ts +4 -0
- package/src/audio.ts +18 -0
- package/src/interfaces/MediaItem.ts +4 -1
- package/src/interfaces/PlayerConfig.ts +24 -3
- package/ios/player/CachingResourceLoader.swift +0 -273
package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sdk=33
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -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
|
|
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 ?? (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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)
|
package/ios/TrackPlayerBridge.mm
CHANGED
|
@@ -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
|
|