@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.
- 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 +46 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +46 -32
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +36 -21
- 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 +19 -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 +22 -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] {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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)
|
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
|
|