react-native-queue-player 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +12 -6
  2. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +21 -3
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderArtwork.kt +30 -0
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoader.kt +9 -6
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +4 -1
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +57 -10
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +53 -20
  8. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +55 -0
  9. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderArtworkTest.kt +72 -0
  10. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoaderTest.kt +21 -9
  11. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceArtworkLoaderTest.kt +9 -9
  12. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +1 -1
  13. package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +103 -0
  14. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +1 -1
  15. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +3 -3
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +7 -7
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +6 -6
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +2 -2
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +1 -1
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +1 -1
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +1 -1
  23. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +1 -1
  24. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +41 -2
  25. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +34 -1
  26. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +1 -1
  27. package/ios/Cast/Core/CastNowPlayingController.swift +7 -4
  28. package/ios/NowPlayingInfo.swift +7 -9
  29. package/ios/PlaceholderArtwork.swift +24 -1
  30. package/ios/QueueSkipArithmetic.swift +55 -9
  31. package/ios/Tests/AVQueueBuilderTests.swift +9 -9
  32. package/ios/Tests/CastNowPlayingControllerTests.swift +33 -0
  33. package/ios/Tests/PlaceholderArtworkTests.swift +81 -0
  34. package/ios/Tests/SkipIndexTests.swift +97 -0
  35. package/ios/TrackPlayer.swift +42 -14
  36. package/lib/module/index.js.map +1 -1
  37. package/lib/module/types.js +9 -0
  38. package/lib/module/types.js.map +1 -1
  39. package/lib/typescript/TrackPlayer.nitro.d.ts +7 -7
  40. package/lib/typescript/index.d.ts +1 -1
  41. package/lib/typescript/index.d.ts.map +1 -1
  42. package/lib/typescript/types.d.ts +41 -0
  43. package/lib/typescript/types.d.ts.map +1 -1
  44. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
  45. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +11 -1
  46. package/nitrogen/generated/android/c++/JSkipToPreviousBehavior.hpp +58 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +12 -2
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/SkipToPreviousBehavior.kt +23 -0
  49. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +18 -0
  50. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  51. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
  52. package/nitrogen/generated/ios/swift/PlayerConfig.swift +30 -1
  53. package/nitrogen/generated/ios/swift/SkipToPreviousBehavior.swift +40 -0
  54. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +12 -1
  55. package/nitrogen/generated/shared/c++/SkipToPreviousBehavior.hpp +76 -0
  56. package/package.json +1 -1
  57. package/src/TrackPlayer.nitro.ts +7 -7
  58. package/src/index.ts +1 -0
  59. package/src/types.ts +42 -0
@@ -40,7 +40,7 @@ class PlaceholderFallbackBitmapLoaderTest {
40
40
 
41
41
  @Test
42
42
  fun `loadBitmapFromMetadata returns placeholder when artwork URI and data are absent`() {
43
- val loader = PlaceholderFallbackBitmapLoader(NeverCalledLoader, placeholder)
43
+ val loader = PlaceholderFallbackBitmapLoader(NeverCalledLoader, { placeholder })
44
44
  val metadata = MediaMetadata.Builder().setTitle("no artwork").build()
45
45
 
46
46
  val future = loader.loadBitmapFromMetadata(metadata)
@@ -49,10 +49,22 @@ class PlaceholderFallbackBitmapLoaderTest {
49
49
  assertSame(placeholder, future!!.get())
50
50
  }
51
51
 
52
+ @Test
53
+ fun `loadBitmapFromMetadata reads the placeholder provider on every call so a live swap is reflected`() {
54
+ val swapped = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888)
55
+ var current = placeholder
56
+ val loader = PlaceholderFallbackBitmapLoader(NeverCalledLoader) { current }
57
+ val metadata = MediaMetadata.Builder().setTitle("no artwork").build()
58
+
59
+ assertSame(placeholder, loader.loadBitmapFromMetadata(metadata)!!.get())
60
+ current = swapped
61
+ assertSame(swapped, loader.loadBitmapFromMetadata(metadata)!!.get())
62
+ }
63
+
52
64
  @Test
53
65
  fun `loadBitmapFromMetadata returns delegate result when artwork URI is present`() {
54
66
  val delegate = StubLoader(metadataResult = Futures.immediateFuture(delegateBitmap))
55
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
67
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
56
68
  val metadata = MediaMetadata.Builder()
57
69
  .setArtworkUri(Uri.parse("https://example.com/cover.jpg"))
58
70
  .build()
@@ -68,7 +80,7 @@ class PlaceholderFallbackBitmapLoaderTest {
68
80
  val delegate = StubLoader(
69
81
  metadataResult = Futures.immediateFailedFuture(RuntimeException("decode failed"))
70
82
  )
71
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
83
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
72
84
  val metadata = MediaMetadata.Builder()
73
85
  .setArtworkUri(Uri.parse("https://example.com/broken.jpg"))
74
86
  .build()
@@ -82,7 +94,7 @@ class PlaceholderFallbackBitmapLoaderTest {
82
94
  @Test
83
95
  fun `loadBitmapFromMetadata returns placeholder when delegate returns null future`() {
84
96
  val delegate = StubLoader(metadataResult = null)
85
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
97
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
86
98
  val metadata = MediaMetadata.Builder()
87
99
  .setArtworkUri(Uri.parse("https://example.com/cover.jpg"))
88
100
  .build()
@@ -98,7 +110,7 @@ class PlaceholderFallbackBitmapLoaderTest {
98
110
  val delegate = StubLoader(
99
111
  uriResult = Futures.immediateFailedFuture(RuntimeException("network error"))
100
112
  )
101
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
113
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
102
114
 
103
115
  val result = loader.loadBitmap(Uri.parse("https://example.com/cover.jpg")).get()
104
116
 
@@ -108,7 +120,7 @@ class PlaceholderFallbackBitmapLoaderTest {
108
120
  @Test
109
121
  fun `loadBitmap returns delegate result on success`() {
110
122
  val delegate = StubLoader(uriResult = Futures.immediateFuture(delegateBitmap))
111
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
123
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
112
124
 
113
125
  val result = loader.loadBitmap(Uri.parse("https://example.com/cover.jpg")).get()
114
126
 
@@ -120,7 +132,7 @@ class PlaceholderFallbackBitmapLoaderTest {
120
132
  val delegate = StubLoader(
121
133
  bytesResult = Futures.immediateFailedFuture(RuntimeException("malformed bytes"))
122
134
  )
123
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
135
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
124
136
 
125
137
  val result = loader.decodeBitmap(byteArrayOf(0x00, 0x01)).get()
126
138
 
@@ -130,7 +142,7 @@ class PlaceholderFallbackBitmapLoaderTest {
130
142
  @Test
131
143
  fun `decodeBitmap returns delegate result on success`() {
132
144
  val delegate = StubLoader(bytesResult = Futures.immediateFuture(delegateBitmap))
133
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
145
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
134
146
 
135
147
  val result = loader.decodeBitmap(byteArrayOf(0x00, 0x01)).get()
136
148
 
@@ -140,7 +152,7 @@ class PlaceholderFallbackBitmapLoaderTest {
140
152
  @Test
141
153
  fun `supportsMimeType proxies through to delegate`() {
142
154
  val delegate = StubLoader(supportedMimeTypes = setOf("image/jpeg"))
143
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
155
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
144
156
 
145
157
  assertTrue(loader.supportsMimeType("image/jpeg"))
146
158
  assertFalse(loader.supportsMimeType("image/heic"))
@@ -80,16 +80,16 @@ class PlaybackServiceArtworkLoaderTest {
80
80
 
81
81
  @Test
82
82
  fun `repeat builds reuse the same placeholder bitmap`() {
83
- // PlaceholderArtwork is process-cached after first read so the
84
- // ~10 KB JPEG decode runs once service-recreate cycles +
85
- // multiple consumer-side reconfigures should not multiply the
86
- // memory cost.
83
+ // The loader holds a placeholder *provider* closure; invoking it
84
+ // reads the process-cached PlaceholderArtwork bitmap so the ~10 KB
85
+ // JPEG decode runs once service-recreate cycles + multiple
86
+ // consumer-side reconfigures do not multiply the memory cost.
87
87
  val first = service.buildBitmapLoader()
88
88
  val second = service.buildBitmapLoader()
89
- val firstPlaceholder = first.javaClass.getDeclaredField("placeholder")
90
- .apply { isAccessible = true }.get(first)
91
- val secondPlaceholder = second.javaClass.getDeclaredField("placeholder")
92
- .apply { isAccessible = true }.get(second)
93
- assertSame(firstPlaceholder, secondPlaceholder)
89
+ val firstProvider = first.javaClass.getDeclaredField("placeholder")
90
+ .apply { isAccessible = true }.get(first) as kotlin.jvm.functions.Function0<*>
91
+ val secondProvider = second.javaClass.getDeclaredField("placeholder")
92
+ .apply { isAccessible = true }.get(second) as kotlin.jvm.functions.Function0<*>
93
+ assertSame(firstProvider.invoke(), secondProvider.invoke())
94
94
  }
95
95
  }
@@ -785,7 +785,7 @@ class PlaybackServiceCallbackTest {
785
785
  audioCategoryOptions = null,
786
786
  visualizationEnabled = null,
787
787
  clampSeekToBuffered = null,
788
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null,
788
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null,
789
789
  ),
790
790
  context,
791
791
  )
@@ -340,4 +340,107 @@ class QueueSkipArithmeticTest {
340
340
  assertFalse(QueueSkipArithmetic.canSkipNext(1, 0, QueueSkipRepeatMode.TRACK))
341
341
  assertFalse(QueueSkipArithmetic.canSkipPrevious(1, 0, QueueSkipRepeatMode.TRACK))
342
342
  }
343
+
344
+ // --- resolveSkipToPrevious — mode OFF (position ignored, current behavior)
345
+
346
+ private fun resolve(
347
+ count: Int = 3, index: Int, mode: QueueSkipRepeatMode,
348
+ restart: Boolean, positionMs: Long
349
+ ) = QueueSkipArithmetic.resolveSkipToPrevious(count, index, mode, restart, positionMs)
350
+
351
+ private val previous1 = QueueSkipArithmetic.SkipToPreviousAction.Previous(1)
352
+ private val previous2 = QueueSkipArithmetic.SkipToPreviousAction.Previous(2)
353
+ private val restart = QueueSkipArithmetic.SkipToPreviousAction.RestartCurrent
354
+ private val noOp = QueueSkipArithmetic.SkipToPreviousAction.NoOp
355
+
356
+ @Test
357
+ fun `resolve mode OFF ignores position and goes previous`() {
358
+ assertEquals(previous1, resolve(index = 2, mode = QueueSkipRepeatMode.OFF, restart = false, positionMs = 99_000L))
359
+ }
360
+
361
+ @Test
362
+ fun `resolve mode OFF first track is noop`() {
363
+ assertEquals(noOp, resolve(index = 0, mode = QueueSkipRepeatMode.OFF, restart = false, positionMs = 99_000L))
364
+ }
365
+
366
+ @Test
367
+ fun `resolve mode OFF queue wraps`() {
368
+ assertEquals(previous2, resolve(index = 0, mode = QueueSkipRepeatMode.QUEUE, restart = false, positionMs = 0L))
369
+ }
370
+
371
+ @Test
372
+ fun `resolve mode OFF empty queue is noop`() {
373
+ assertEquals(noOp, resolve(count = 0, index = -1, mode = QueueSkipRepeatMode.OFF, restart = false, positionMs = 0L))
374
+ }
375
+
376
+ // --- resolveSkipToPrevious — mode ON (3s threshold model)
377
+
378
+ @Test
379
+ fun `resolve past threshold restarts even with a previous`() {
380
+ for (pos in listOf(3001L, 5000L, 600_000L)) {
381
+ assertEquals("position $pos > 3000 restarts", restart,
382
+ resolve(index = 2, mode = QueueSkipRepeatMode.OFF, restart = true, positionMs = pos))
383
+ }
384
+ }
385
+
386
+ @Test
387
+ fun `resolve threshold boundary goes previous, one past restarts`() {
388
+ assertEquals(previous1, resolve(index = 2, mode = QueueSkipRepeatMode.OFF, restart = true, positionMs = 3000L))
389
+ assertEquals(restart, resolve(index = 2, mode = QueueSkipRepeatMode.OFF, restart = true, positionMs = 3001L))
390
+ }
391
+
392
+ @Test
393
+ fun `resolve under threshold goes previous`() {
394
+ for (pos in listOf(0L, 1L, 2999L)) {
395
+ assertEquals("position $pos <= 3000 goes previous", previous1,
396
+ resolve(index = 2, mode = QueueSkipRepeatMode.OFF, restart = true, positionMs = pos))
397
+ }
398
+ }
399
+
400
+ @Test
401
+ fun `resolve first track under threshold restarts when no previous`() {
402
+ assertEquals(restart, resolve(index = 0, mode = QueueSkipRepeatMode.OFF, restart = true, positionMs = 1000L))
403
+ assertEquals(restart, resolve(index = 0, mode = QueueSkipRepeatMode.TRACK, restart = true, positionMs = 1000L))
404
+ }
405
+
406
+ @Test
407
+ fun `resolve first track under threshold queue wraps`() {
408
+ assertEquals(previous2, resolve(index = 0, mode = QueueSkipRepeatMode.QUEUE, restart = true, positionMs = 1000L))
409
+ }
410
+
411
+ @Test
412
+ fun `resolve first track past threshold restarts under every mode`() {
413
+ for (mode in QueueSkipRepeatMode.values()) {
414
+ assertEquals("first track past threshold restarts under $mode", restart,
415
+ resolve(index = 0, mode = mode, restart = true, positionMs = 9000L))
416
+ }
417
+ }
418
+
419
+ @Test
420
+ fun `resolve mode ON empty queue is noop`() {
421
+ assertEquals(noOp, resolve(count = 0, index = -1, mode = QueueSkipRepeatMode.OFF, restart = true, positionMs = 9000L))
422
+ }
423
+
424
+ // --- canSkipPrevious with restartEnabled (forced true)
425
+
426
+ @Test
427
+ fun `canSkipPrevious forced true at every index and mode`() {
428
+ for (mode in QueueSkipRepeatMode.values()) {
429
+ for (index in 0 until 3) {
430
+ assertTrue("restart mode forces canSkipPrevious true at index $index under $mode",
431
+ QueueSkipArithmetic.canSkipPrevious(3, index, mode, restartEnabled = true))
432
+ }
433
+ }
434
+ }
435
+
436
+ @Test
437
+ fun `canSkipPrevious restartEnabled false for empty queue`() {
438
+ assertFalse(QueueSkipArithmetic.canSkipPrevious(0, -1, QueueSkipRepeatMode.OFF, restartEnabled = true))
439
+ }
440
+
441
+ @Test
442
+ fun `canSkipPrevious restart disabled matches existing`() {
443
+ assertFalse(QueueSkipArithmetic.canSkipPrevious(3, 0, QueueSkipRepeatMode.OFF, restartEnabled = false))
444
+ assertTrue(QueueSkipArithmetic.canSkipPrevious(3, 1, QueueSkipRepeatMode.OFF, restartEnabled = false))
445
+ }
343
446
  }
@@ -66,7 +66,7 @@ class TrackPlayerAirPlayMetadataWiringTest {
66
66
  retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null,
67
67
  audioCategoryOptions = null, visualizationEnabled = null,
68
68
  clampSeekToBuffered = null,
69
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null,
69
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null,
70
70
  ),
71
71
  context(),
72
72
  )
@@ -44,7 +44,7 @@ class TrackPlayerAudioFocusTest {
44
44
  player = TrackPlayer()
45
45
  serviceHandle = bindPlaybackServiceForTest(player, context())
46
46
  player.configureInternal(
47
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
47
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
48
48
  )
49
49
  }
50
50
 
@@ -51,7 +51,7 @@ class TrackPlayerEventsTest {
51
51
  player = TrackPlayer()
52
52
  serviceHandle = bindPlaybackServiceForTest(player, context())
53
53
  player.configureInternal(
54
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
54
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
55
55
  )
56
56
  }
57
57
 
@@ -230,7 +230,7 @@ class TrackPlayerEventsTest {
230
230
  // the public effects — a new configure + getStateInternal
231
231
  // should return NONE.
232
232
  player.configureInternal(
233
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
233
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
234
234
  )
235
235
  assertEquals(PlayerState.NONE, player.getStateInternal())
236
236
  }
@@ -479,7 +479,7 @@ class TrackPlayerEventsTest {
479
479
  serviceHandle.destroy()
480
480
  serviceHandle = bindPlaybackServiceForTest(player, context())
481
481
  player.configureInternal(
482
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
482
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
483
483
  )
484
484
  // No listener is bound to the new ExoPlayer's actual STATE_ENDED
485
485
  // path in a Robolectric test, but the callback slot itself is
@@ -68,7 +68,7 @@ class TrackPlayerLifecycleTest {
68
68
  autoRetries = null,
69
69
  retryBackoffMs = null,
70
70
  networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
71
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
71
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
72
72
  )
73
73
  player.configureInternal(config, context())
74
74
 
@@ -92,7 +92,7 @@ class TrackPlayerLifecycleTest {
92
92
  // public path owns the destroy step. This test drives the
93
93
  // destroy + reconstruct shape directly to mirror what
94
94
  // `configure()` does end-to-end.
95
- val empty = PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null)
95
+ val empty = PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null)
96
96
 
97
97
  player.configureInternal(empty, context())
98
98
  val firstInstance = player.player
@@ -125,12 +125,12 @@ class TrackPlayerLifecycleTest {
125
125
  val first = PlayerConfig(
126
126
  httpHeaders = mapOf("A" to "1"), userAgent = "ua-1",
127
127
  autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
128
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
128
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
129
129
  )
130
130
  val second = PlayerConfig(
131
131
  httpHeaders = mapOf("B" to "2"), userAgent = "ua-2",
132
132
  autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
133
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
133
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
134
134
  )
135
135
 
136
136
  player.configureInternal(first, context())
@@ -144,7 +144,7 @@ class TrackPlayerLifecycleTest {
144
144
  val configured = PlayerConfig(
145
145
  httpHeaders = mapOf("h" to "v"), userAgent = "ua",
146
146
  autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
147
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
147
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
148
148
  )
149
149
  player.configureInternal(configured, context())
150
150
  // Non-default playback state the consumer set post-configure.
@@ -180,7 +180,7 @@ class TrackPlayerLifecycleTest {
180
180
  @Test
181
181
  fun `destroyInternal is safe to call twice`() {
182
182
  player.configureInternal(
183
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
183
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
184
184
  )
185
185
  player.destroyInternal()
186
186
  player.destroyInternal()
@@ -189,7 +189,7 @@ class TrackPlayerLifecycleTest {
189
189
 
190
190
  @Test
191
191
  fun `configureInternal after destroyInternal rebuilds the ExoPlayer`() {
192
- val cfg = PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null)
192
+ val cfg = PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null)
193
193
 
194
194
  player.configureInternal(cfg, context())
195
195
  val first = player.player
@@ -80,7 +80,7 @@ class TrackPlayerLookaheadConfigTest {
80
80
  httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null,
81
81
  networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null,
82
82
  visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = 250.0, lookaheadCacheEvictionPolicy = null,
83
- progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
83
+ progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
84
84
  ),
85
85
  context()
86
86
  )
@@ -120,7 +120,7 @@ class TrackPlayerLookaheadConfigTest {
120
120
  networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null,
121
121
  visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null,
122
122
  lookaheadCacheEvictionPolicy = policy,
123
- progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
123
+ progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
124
124
  )
125
125
 
126
126
  // --- setLookaheadCacheInternal
@@ -298,7 +298,7 @@ class TrackPlayerLookaheadConfigTest {
298
298
  @Test
299
299
  fun `re-configure with changed httpHeaders rebuilds the player (writer + cache)`() {
300
300
  player.configureInternal(
301
- PlayerConfig(httpHeaders = mapOf("X-Auth" to "token-1"), userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null),
301
+ PlayerConfig(httpHeaders = mapOf("X-Auth" to "token-1"), userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null),
302
302
  context()
303
303
  )
304
304
  val firstWriter = player.lookaheadCacheWriter
@@ -307,7 +307,7 @@ class TrackPlayerLookaheadConfigTest {
307
307
  assertNotNull(firstCache)
308
308
 
309
309
  player.configureInternal(
310
- PlayerConfig(httpHeaders = mapOf("X-Auth" to "token-2"), userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null),
310
+ PlayerConfig(httpHeaders = mapOf("X-Auth" to "token-2"), userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null),
311
311
  context()
312
312
  )
313
313
 
@@ -339,7 +339,7 @@ class TrackPlayerLookaheadConfigTest {
339
339
  // simulated here by `destroyInternal` between
340
340
  // `configureInternal` calls) yields a fresh writer instance,
341
341
  // even when headers are unchanged.
342
- val cfg = PlayerConfig(httpHeaders = mapOf("X-Auth" to "token"), userAgent = "rnqp/test", autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null)
342
+ val cfg = PlayerConfig(httpHeaders = mapOf("X-Auth" to "token"), userAgent = "rnqp/test", autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null)
343
343
  player.configureInternal(cfg, context())
344
344
  val firstWriter = player.lookaheadCacheWriter
345
345
 
@@ -413,5 +413,5 @@ class TrackPlayerLookaheadConfigTest {
413
413
 
414
414
  // --- Helpers
415
415
 
416
- private fun emptyConfig() = PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null)
416
+ private fun emptyConfig() = PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null)
417
417
  }
@@ -45,7 +45,7 @@ class TrackPlayerMutationTest {
45
45
  player = TrackPlayer()
46
46
  serviceHandle = bindPlaybackServiceForTest(player, context())
47
47
  player.configureInternal(
48
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
48
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
49
49
  )
50
50
  player.setQueueInternal((1..5).map { track("t$it") })
51
51
  // cur defaults to 0 after setQueue; tests needing mid-queue
@@ -127,7 +127,7 @@ class TrackPlayerMutationTest {
127
127
  val freshHandle = bindPlaybackServiceForTest(fresh, context())
128
128
  try {
129
129
  fresh.configureInternal(
130
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
130
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
131
131
  )
132
132
  assertEquals(-1, fresh.currentTrackIndex)
133
133
  assertEquals(Player.STATE_IDLE, fresh.player!!.playbackState)
@@ -35,7 +35,7 @@ class TrackPlayerProgressThrottleTest {
35
35
  httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null,
36
36
  networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null,
37
37
  visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null,
38
- progressUpdateIntervalMs = foregroundMs, backgroundProgressUpdateIntervalMs = backgroundMs
38
+ progressUpdateIntervalMs = foregroundMs, backgroundProgressUpdateIntervalMs = backgroundMs, placeholderArtworkUri = null, skipToPreviousBehavior = null
39
39
  )
40
40
 
41
41
  @Before
@@ -47,7 +47,7 @@ class TrackPlayerQueueChangeTest {
47
47
  player = TrackPlayer()
48
48
  serviceHandle = bindPlaybackServiceForTest(player, context())
49
49
  player.configureInternal(
50
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
50
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
51
51
  )
52
52
  player.setQueueInternal((1..5).map { track("t$it") })
53
53
  }
@@ -55,7 +55,7 @@ class TrackPlayerQueueTest {
55
55
  player = TrackPlayer()
56
56
  serviceHandle = bindPlaybackServiceForTest(player, context())
57
57
  player.configureInternal(
58
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
58
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
59
59
  )
60
60
  }
61
61
 
@@ -42,7 +42,7 @@ class TrackPlayerReadersTest {
42
42
  httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null,
43
43
  networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null,
44
44
  visualizationEnabled = null, clampSeekToBuffered = null,
45
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
45
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
46
46
  ),
47
47
  context()
48
48
  )
@@ -53,7 +53,7 @@ class TrackPlayerSkipCapabilityTest {
53
53
  player = TrackPlayer()
54
54
  serviceHandle = bindPlaybackServiceForTest(player, context())
55
55
  player.configureInternal(
56
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
56
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
57
57
  )
58
58
  }
59
59
 
@@ -318,7 +318,46 @@ class TrackPlayerSkipCapabilityTest {
318
318
  player = TrackPlayer()
319
319
  serviceHandle = bindPlaybackServiceForTest(player, context())
320
320
  player.configureInternal(
321
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
321
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
322
+ )
323
+ }
324
+
325
+ // --- skipToPreviousBehavior = restart-or-previous forces canSkipPrevious true
326
+
327
+ private fun configureRestart() {
328
+ player.configureInternal(
329
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = SkipToPreviousBehavior.RESTART_OR_PREVIOUS), context()
330
+ )
331
+ }
332
+
333
+ @Test
334
+ fun `restart mode forces canSkipPrevious true at index 0`() {
335
+ configureRestart()
336
+ player.setQueueInternal((1..5).map { track("t$it") })
337
+ // At index 0 under OFF the default mode reports false (no previous);
338
+ // restart mode forces it true so Previous never greys out.
339
+ assertEquals(true, player.getCanSkipPrevious())
340
+ assertEquals(true, player.getCanSkipNext())
341
+ }
342
+
343
+ @Test
344
+ fun `default previous mode leaves canSkipPrevious false at index 0`() {
345
+ // setUp configured skipToPreviousBehavior = null (= 'previous').
346
+ player.setQueueInternal((1..5).map { track("t$it") })
347
+ assertEquals(false, player.getCanSkipPrevious())
348
+ }
349
+
350
+ @Test
351
+ fun `restart mode emits canSkipPrevious true via onSkipCapabilityChange`() {
352
+ configureRestart()
353
+ val emissions = mutableListOf<SkipCapability>()
354
+ player.onSkipCapabilityChange { cap -> emissions.add(cap) }
355
+ flushMain() // auto-fire: empty queue → (false, false)
356
+ player.setQueueInternal((1..5).map { track("t$it") })
357
+ flushMain()
358
+ assertTrue(
359
+ "restart mode makes canSkipPrevious true once a track is loaded",
360
+ emissions.last().canSkipPrevious
322
361
  )
323
362
  }
324
363
  }
@@ -51,7 +51,7 @@ class TrackPlayerSkipTest {
51
51
  player = TrackPlayer()
52
52
  serviceHandle = bindPlaybackServiceForTest(player, context())
53
53
  player.configureInternal(
54
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
54
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
55
55
  )
56
56
  player.tracks = (1..5).map { track("t$it") }
57
57
  player.currentTrackIndex = 2 // mid-queue default for most cases
@@ -284,4 +284,37 @@ class TrackPlayerSkipTest {
284
284
  0, fresh.currentTrackIndex
285
285
  )
286
286
  }
287
+
288
+ // --- skipToPreviousInternal with skipToPreviousBehavior = restart-or-previous
289
+ //
290
+ // The test harness leaves the ExoPlayer timeline empty, so engine position
291
+ // reads 0 (<= the 3s threshold). Past-threshold restart with a real position
292
+ // is covered by the pure `QueueSkipArithmeticTest` matrix + the demo suite.
293
+
294
+ private fun configureSkipBehavior(behavior: SkipToPreviousBehavior?) {
295
+ player.configureInternal(
296
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = behavior), context()
297
+ )
298
+ player.tracks = (1..5).map { track("t$it") }
299
+ }
300
+
301
+ @Test
302
+ fun `skipToPreviousInternal restart mode under threshold goes to previous track`() {
303
+ configureSkipBehavior(SkipToPreviousBehavior.RESTART_OR_PREVIOUS)
304
+ player.repeatModeState = RepeatMode.OFF
305
+ player.currentTrackIndex = 3
306
+ player.skipToPreviousInternal()
307
+ assertEquals("position 0 <= threshold → previous track", 2, player.currentTrackIndex)
308
+ }
309
+
310
+ @Test
311
+ fun `skipToPreviousInternal restart mode at first track restarts, index unchanged`() {
312
+ configureSkipBehavior(SkipToPreviousBehavior.RESTART_OR_PREVIOUS)
313
+ player.repeatModeState = RepeatMode.OFF
314
+ player.currentTrackIndex = 0
315
+ // No previous track → restart current; the restart branch seeks to 0 and
316
+ // leaves currentTrackIndex put (a seek, not a skip).
317
+ player.skipToPreviousInternal()
318
+ assertEquals("restart leaves the index unchanged", 0, player.currentTrackIndex)
319
+ }
287
320
  }
@@ -50,7 +50,7 @@ class TrackPlayerTransportTest {
50
50
  player = TrackPlayer()
51
51
  serviceHandle = bindPlaybackServiceForTest(player, context())
52
52
  player.configureInternal(
53
- PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
53
+ PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null), context()
54
54
  )
55
55
  }
56
56
 
@@ -35,12 +35,15 @@ final class CastNowPlayingController {
35
35
  /// `TrackPlayer.configure` via `configureArtwork`.
36
36
  private let artworkResolver = ArtworkResolver()
37
37
 
38
- /// Bundled placeholder shown until the real cover resolves, so the
39
- /// lockscreen never renders artwork-less during the network fetch.
40
- private static let placeholderArtwork: MPMediaItemArtwork = {
38
+ /// Placeholder shown until the real cover resolves, so the lockscreen
39
+ /// never renders artwork-less during the network fetch. Read live —
40
+ /// `PlaceholderArtwork.image` is the consumer-configured image when one
41
+ /// is set (`PlayerConfig.placeholderArtworkUri`), else the bundled one —
42
+ /// so a per-refresh write here never clobbers it back to the built-in.
43
+ private static var placeholderArtwork: MPMediaItemArtwork {
41
44
  let image = PlaceholderArtwork.image
42
45
  return MPMediaItemArtwork(boundsSize: image.size) { _ in image }
43
- }()
46
+ }
44
47
 
45
48
  /// Snapshot of the active receiver track for the lockscreen surface.
46
49
  /// Pushed by `TrackPlayer` when the receiver's current track (resolved