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
package/README.md CHANGED
@@ -140,9 +140,17 @@ Lock-screen / Now Playing controls and remote commands work automatically once t
140
140
 
141
141
  ## Common UX patterns
142
142
 
143
- ### Apple-Music-style "tap Previous restarts when position > 3s"
143
+ ### Restart-or-previous skip-back (the "tap Previous restarts, then goes back" model)
144
144
 
145
- The library ships the **primitive** (`canSkipPrevious` = "is there a previous track under the current repeat mode") and leaves the **policy** (rewind-vs-skip threshold) to your app:
145
+ The standard music-player behaviour — a skip-back restarts the current track when it's past a few seconds, otherwise goes to the previous track is built in. Enable it with `skipToPreviousBehavior`:
146
+
147
+ ```ts
148
+ await player.configure({ skipToPreviousBehavior: 'restart-or-previous' });
149
+ ```
150
+
151
+ Now every `skipToPrevious()` — from your UI, the lock screen, Bluetooth, Android Auto, or CarPlay — restarts the current track (seeks to 0) when the position is past ~3 s, and goes to the previous track under it. Because it's native, it stays consistent across every surface (a JS handler can't cleanly intercept the OS Previous button), and the restart is a seek so it does **not** fire `onTrackChange`. With the option enabled, `canSkipPrevious` stays `true` while a track is loaded, so the Previous control never greys out — even on the first track. The default `'previous'` keeps skip-back always going to the previous track. (Local playback only; during cast, Previous routes to the receiver.)
152
+
153
+ For a different threshold or bespoke logic on **your own** in-app button (the OS controls follow `skipToPreviousBehavior`), leave the option at its default and branch on the live position:
146
154
 
147
155
  ```tsx
148
156
  import { getTrackPlayer, useProgress } from 'react-native-queue-player';
@@ -151,18 +159,16 @@ function PreviousButton() {
151
159
  const { position } = useProgress();
152
160
  const onPress = async () => {
153
161
  const player = getTrackPlayer();
154
- if (position > 3) await player.seekTo(0);
162
+ if (position > 5) await player.seekTo(0);
155
163
  else await player.skipToPrevious();
156
164
  };
157
165
  return /* your <Button> here */;
158
166
  }
159
167
  ```
160
168
 
161
- The 3-second threshold is the Apple Music default; tune to taste.
162
-
163
169
  ### Skip-button enable/disable from `useCanSkip`
164
170
 
165
- `canSkipNext` / `canSkipPrevious` already account for repeat mode: under `off` they go false at queue boundaries; under `queue` they wrap and stay true; under `track` they stay true.
171
+ `canSkipNext` / `canSkipPrevious` account for repeat mode: under `off` they go false at queue boundaries; under `queue` they wrap and stay true; under `track` they stay true. With `skipToPreviousBehavior: 'restart-or-previous'`, `canSkipPrevious` stays true whenever a track is loaded (the Previous control never greys out).
166
172
 
167
173
  ```tsx
168
174
  const { canSkipNext, canSkipPrevious } = useCanSkip();
@@ -57,7 +57,8 @@ object MediaItemBuilder {
57
57
  fun buildMediaItems(
58
58
  context: Context,
59
59
  tracks: List<TrackItem>,
60
- lookaheadCache: LookaheadCache? = null
60
+ lookaheadCache: LookaheadCache? = null,
61
+ customPlaceholderUri: String? = null
61
62
  ): List<MediaItem> {
62
63
  return tracks.map { track ->
63
64
  val builder = MediaItem.Builder()
@@ -68,7 +69,7 @@ object MediaItemBuilder {
68
69
  // Media3's documented default when track.id is omitted.
69
70
  .setMediaId(track.id ?: "")
70
71
  .setUri(Uri.parse(track.url))
71
- .setMediaMetadata(buildMetadata(context, track))
72
+ .setMediaMetadata(buildMetadata(context, track, customPlaceholderUri))
72
73
  val mime = resolveMimeTypeForBuild(track.url, lookaheadCache)
73
74
  if (mime != null) builder.setMimeType(mime)
74
75
  builder.build()
@@ -86,13 +87,26 @@ object MediaItemBuilder {
86
87
  * so Media3's BitmapLoader resolves it once per process rather than
87
88
  * cloning a byte[] into every MediaMetadata.
88
89
  */
89
- private fun buildMetadata(context: Context, track: TrackItem): MediaMetadata {
90
+ private fun buildMetadata(
91
+ context: Context,
92
+ track: TrackItem,
93
+ customPlaceholderUri: String?
94
+ ): MediaMetadata {
90
95
  val builder = MediaMetadata.Builder()
91
96
  .setTitle(track.title.orEmpty())
92
97
  .setArtist(track.artist.orEmpty())
93
98
  .setAlbumTitle(track.album.orEmpty())
94
99
 
95
100
  val artworkUrl = track.artworkUrl?.trim()
101
+ // Only a local `file://` placeholder is honoured (the built-in is a
102
+ // local file; a remote URL would break offline + change behaviour).
103
+ // Scheme match is case-insensitive to mirror iOS's `URL.isFileURL`;
104
+ // normalize to a lowercase `file` scheme so the case-sensitive
105
+ // `content://` rewrite below applies to an uppercase `FILE://` too.
106
+ val customFileUri = customPlaceholderUri
107
+ ?.let { Uri.parse(it) }
108
+ ?.takeIf { it.scheme.equals("file", ignoreCase = true) }
109
+ ?.buildUpon()?.scheme("file")?.build()
96
110
  val artworkUri = if (!artworkUrl.isNullOrEmpty()) {
97
111
  // Rewrite private `file://` paths through the lib's exported
98
112
  // `ArtworkContentProvider` so cross-process controllers (Android
@@ -100,6 +114,10 @@ object MediaItemBuilder {
100
114
  // their own process. `http(s)://` and `content://` URIs pass
101
115
  // through unchanged.
102
116
  ArtworkContentProvider.mapFileUri(Uri.parse(artworkUrl))
117
+ } else if (customFileUri != null) {
118
+ // Consumer-supplied placeholder in place of the built-in — same
119
+ // cross-process file-URI rewrite as the real-art branch.
120
+ ArtworkContentProvider.mapFileUri(customFileUri)
103
121
  } else {
104
122
  placeholderArtworkUri(context)
105
123
  }
@@ -3,6 +3,7 @@ package com.margelo.nitro.queueplayer
3
3
  import android.content.Context
4
4
  import android.graphics.Bitmap
5
5
  import android.graphics.BitmapFactory
6
+ import android.net.Uri
6
7
 
7
8
  /**
8
9
  * Bundled fallback artwork used by the MediaSession + notification
@@ -26,8 +27,10 @@ internal object PlaceholderArtwork {
26
27
  * to call from any thread (the bitmap is immutable once decoded).
27
28
  */
28
29
  fun bitmap(context: Context): Bitmap {
30
+ customBitmap?.let { return it }
29
31
  cached?.let { return it }
30
32
  synchronized(this) {
33
+ customBitmap?.let { return it }
31
34
  cached?.let { return it }
32
35
  val decoded = decode(context)
33
36
  cached = decoded
@@ -35,6 +38,32 @@ internal object PlaceholderArtwork {
35
38
  }
36
39
  }
37
40
 
41
+ /**
42
+ * Set (or clear) the consumer-supplied placeholder from
43
+ * `PlayerConfig.placeholderArtworkUri`. Decoded once from the local
44
+ * `file://` path via `BitmapFactory.decodeFile` (scheme match is
45
+ * case-insensitive); a non-`file://` URI, an unset URI, or a decode
46
+ * failure leaves the bundled placeholder as the floor. Decode is
47
+ * synchronous, so a very large consumer image briefly blocks the
48
+ * `configure` call. Called from configure.
49
+ */
50
+ fun setCustom(uri: String?) {
51
+ synchronized(this) {
52
+ // Local file:// only — remote URLs would break offline + change
53
+ // behaviour, so anything else leaves the built-in as the floor.
54
+ customBitmap = uri
55
+ ?.takeIf { Uri.parse(it).scheme.equals("file", ignoreCase = true) }
56
+ ?.let { decodeUri(it) }
57
+ }
58
+ }
59
+
60
+ private fun decodeUri(uri: String): Bitmap? =
61
+ try {
62
+ Uri.parse(uri).path?.let { BitmapFactory.decodeFile(it) }
63
+ } catch (e: Exception) {
64
+ null
65
+ }
66
+
38
67
  /**
39
68
  * Returns the placeholder bytes (raw JPEG payload). Useful for
40
69
  * paths that want to write the bytes into a `MediaMetadata`
@@ -53,6 +82,7 @@ internal object PlaceholderArtwork {
53
82
 
54
83
  @Volatile private var cached: Bitmap? = null
55
84
  @Volatile private var cachedBytes: ByteArray? = null
85
+ @Volatile private var customBitmap: Bitmap? = null
56
86
 
57
87
  private fun decode(context: Context): Bitmap {
58
88
  val stream = context.resources.openRawResource(R.raw.rnqp_placeholder)
@@ -37,7 +37,10 @@ import com.google.common.util.concurrent.MoreExecutors
37
37
  @UnstableApi
38
38
  internal class PlaceholderFallbackBitmapLoader(
39
39
  private val delegate: BitmapLoader,
40
- private val placeholder: Bitmap,
40
+ // Read on each fallback (not captured at construction) so a placeholder
41
+ // swapped at `configure` — after the session/loader was built in
42
+ // `onCreate` — is reflected without rebuilding the loader.
43
+ private val placeholder: () -> Bitmap,
41
44
  ) : BitmapLoader {
42
45
 
43
46
  override fun supportsMimeType(mimeType: String): Boolean =
@@ -47,7 +50,7 @@ internal class PlaceholderFallbackBitmapLoader(
47
50
  Futures.catching(
48
51
  delegate.decodeBitmap(data),
49
52
  Throwable::class.java,
50
- { placeholder },
53
+ { placeholder() },
51
54
  MoreExecutors.directExecutor(),
52
55
  )
53
56
 
@@ -55,20 +58,20 @@ internal class PlaceholderFallbackBitmapLoader(
55
58
  Futures.catching(
56
59
  delegate.loadBitmap(uri),
57
60
  Throwable::class.java,
58
- { placeholder },
61
+ { placeholder() },
59
62
  MoreExecutors.directExecutor(),
60
63
  )
61
64
 
62
65
  override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture<Bitmap>? {
63
66
  if (metadata.artworkUri == null && metadata.artworkData == null) {
64
- return Futures.immediateFuture(placeholder)
67
+ return Futures.immediateFuture(placeholder())
65
68
  }
66
69
  val delegated = delegate.loadBitmapFromMetadata(metadata)
67
- ?: return Futures.immediateFuture(placeholder)
70
+ ?: return Futures.immediateFuture(placeholder())
68
71
  return Futures.catching(
69
72
  delegated,
70
73
  Throwable::class.java,
71
- { placeholder },
74
+ { placeholder() },
72
75
  MoreExecutors.directExecutor(),
73
76
  )
74
77
  }
@@ -777,7 +777,10 @@ class PlaybackService : HeadlessJsMediaService() {
777
777
  internal fun buildBitmapLoader(): PlaceholderFallbackBitmapLoader =
778
778
  PlaceholderFallbackBitmapLoader(
779
779
  delegate = CacheBitmapLoader(DataSourceBitmapLoader.Builder(this).build()),
780
- placeholder = PlaceholderArtwork.bitmap(this),
780
+ // Read on each fallback so a placeholder set at `configure` (after
781
+ // this loader was built in onCreate) is reflected — `PlaceholderArtwork`
782
+ // returns the consumer image when one is configured, else the built-in.
783
+ placeholder = { PlaceholderArtwork.bitmap(applicationContext) },
781
784
  )
782
785
 
783
786
  /**
@@ -59,9 +59,9 @@ internal object QueueSkipArithmetic {
59
59
  * Previous-track index. Same early-bail rules as [computeNext].
60
60
  *
61
61
  * OFF and TRACK both return null at index 0 (no previous track, no
62
- * wrap). Apps that want the Apple-Music-style "tap Previous at
63
- * track 0 restarts current" UX implement that in their button
64
- * handler.
62
+ * wrap). The "restart current when past the threshold" model is
63
+ * available via `PlayerConfig.skipToPreviousBehavior` (resolved by
64
+ * [resolveSkipToPrevious]).
65
65
  */
66
66
  fun computePrevious(
67
67
  trackCount: Int,
@@ -99,15 +99,62 @@ internal object QueueSkipArithmetic {
99
99
  * OFF and TRACK are false at index 0 (no wrap); QUEUE is always
100
100
  * true with an active track (wraps).
101
101
  *
102
- * **Apps that want the Apple-Music-style "tap Previous restarts
103
- * when position > 3s" UX** should implement that in their button
104
- * handler: read playback position, branch to `seekTo(0)` vs
105
- * `skipToPrevious()` themselves. Lib intentionally ships the
106
- * primitive (skip if there's a track to skip to), not the policy.
102
+ * When [restartEnabled] is true (`PlayerConfig.skipToPreviousBehavior
103
+ * == RESTART_OR_PREVIOUS`), Previous is enabled whenever a track is
104
+ * loaded even at index 0, where past the threshold it restarts the
105
+ * current track so the control never greys out.
107
106
  */
108
107
  fun canSkipPrevious(
109
108
  trackCount: Int,
110
109
  currentIndex: Int,
111
- repeatMode: QueueSkipRepeatMode
112
- ): Boolean = computePrevious(trackCount, currentIndex, repeatMode) != null
110
+ repeatMode: QueueSkipRepeatMode,
111
+ restartEnabled: Boolean = false
112
+ ): Boolean =
113
+ if (restartEnabled) trackCount > 0
114
+ else computePrevious(trackCount, currentIndex, repeatMode) != null
115
+
116
+ /**
117
+ * Position (ms) at or below which a restart-or-previous skip-back goes
118
+ * to the previous track; past it, the current track restarts. Matches
119
+ * Media3's default `maxSeekToPreviousPosition` (industry-standard 3 s).
120
+ */
121
+ const val RESTART_THRESHOLD_MS: Long = 3000L
122
+
123
+ /** What a skip-to-previous should do, resolved from position + behavior. */
124
+ sealed interface SkipToPreviousAction {
125
+ /** Navigate to this queue index (a real track change). */
126
+ data class Previous(val index: Int) : SkipToPreviousAction
127
+ /** Restart the current track (seek to 0) — not a track change. */
128
+ object RestartCurrent : SkipToPreviousAction
129
+ /** Nothing to do (empty queue). */
130
+ object NoOp : SkipToPreviousAction
131
+ }
132
+
133
+ /**
134
+ * Resolves a skip-to-previous. When [restartEnabled] is false this is
135
+ * exactly [computePrevious] (previous track, or nothing). When true it
136
+ * applies the threshold model, matching Media3 `BasePlayer.seekToPrevious`:
137
+ * a previous track exists AND `positionMs <= thresholdMs` -> previous;
138
+ * otherwise (past threshold, or first track with no previous) -> restart
139
+ * the current track.
140
+ */
141
+ fun resolveSkipToPrevious(
142
+ trackCount: Int,
143
+ currentIndex: Int,
144
+ repeatMode: QueueSkipRepeatMode,
145
+ restartEnabled: Boolean,
146
+ positionMs: Long,
147
+ thresholdMs: Long = RESTART_THRESHOLD_MS
148
+ ): SkipToPreviousAction {
149
+ if (trackCount <= 0 || currentIndex < 0) return SkipToPreviousAction.NoOp
150
+ val prev = computePrevious(trackCount, currentIndex, repeatMode)
151
+ if (!restartEnabled) {
152
+ return if (prev != null) SkipToPreviousAction.Previous(prev) else SkipToPreviousAction.NoOp
153
+ }
154
+ return if (prev != null && positionMs <= thresholdMs) {
155
+ SkipToPreviousAction.Previous(prev)
156
+ } else {
157
+ SkipToPreviousAction.RestartCurrent
158
+ }
159
+ }
113
160
  }
@@ -201,7 +201,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
201
201
  visualizationEnabled = null,
202
202
  clampSeekToBuffered = null,
203
203
  lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null,
204
- progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
204
+ progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null, skipToPreviousBehavior = null
205
205
  )
206
206
 
207
207
  @Volatile
@@ -880,6 +880,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
880
880
  // shared instance is released and its directory deleted below.
881
881
  val previousEvictionPolicy = evictionPolicyFrom(this.config)
882
882
  this.config = newConfig
883
+ // Swap the cover-art placeholder bitmap to the consumer-supplied image
884
+ // (or back to the built-in when unset). The no-art metadata URI is
885
+ // threaded separately at each buildMediaItems call.
886
+ PlaceholderArtwork.setCustom(newConfig.placeholderArtworkUri)
883
887
  if (evictionPolicyFrom(newConfig) != previousEvictionPolicy) {
884
888
  lookaheadCacheWriter?.release()
885
889
  lookaheadCacheWriter = null
@@ -1480,7 +1484,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1480
1484
  val attempts = effectiveAutoRetries()
1481
1485
  retryAttemptsRemaining =
1482
1486
  (newTracks.indices.associateWith { attempts }).toMutableMap()
1483
- return MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache)
1487
+ return MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
1484
1488
  }
1485
1489
 
1486
1490
  /**
@@ -1578,7 +1582,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1578
1582
  for (newIdx in at until at + shift) {
1579
1583
  retryAttemptsRemaining[newIdx] = attempts
1580
1584
  }
1581
- val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache)
1585
+ val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
1582
1586
  serviceBinder?.engine?.addMediaItems(items, insertBefore = at)
1583
1587
  // Diverges from iOS: add-into-empty-queue on iOS leaves the
1584
1588
  // AVQueuePlayer empty until the consumer calls skipToIndex(0).
@@ -2167,25 +2171,46 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2167
2171
  }
2168
2172
 
2169
2173
  /**
2170
- * Standard skip-previous: go to the previous track if there is one
2171
- * (per the arithmetic helper; OFF/TRACK don't wrap, QUEUE wraps).
2172
- * Under TRACK this navigates like OFF and repeat-one persists. Apps
2173
- * that want the Apple-Music-style "tap Previous restarts when
2174
- * position > 3s" UX implement that in their button handler — the
2175
- * lib provides the primitive (skip if there's a track to skip to),
2176
- * not the policy.
2174
+ * Skip-previous, resolved by [QueueSkipArithmetic.resolveSkipToPrevious]
2175
+ * from `PlayerConfig.skipToPreviousBehavior`. With the default PREVIOUS this
2176
+ * goes to the previous track if there is one (OFF/TRACK don't wrap, QUEUE
2177
+ * wraps; TRACK navigates like OFF and repeat-one persists). With
2178
+ * RESTART_OR_PREVIOUS it restarts the current track (seek to 0) when past
2179
+ * the threshold, otherwise goes to the previous track.
2177
2180
  */
2178
2181
  @VisibleForTesting
2179
2182
  internal fun skipToPreviousInternal() {
2180
2183
  val p = player ?: return
2181
- val prev = computePreviousIndex() ?: return
2182
- currentTrackIndex = prev
2183
- pendingTrackChangeReason = TrackChangeReason.USER_SKIP_PREVIOUS
2184
- if (prev < p.mediaItemCount) {
2185
- serviceBinder?.engine?.seekTo(prev, 0L)
2184
+ val action = QueueSkipArithmetic.resolveSkipToPrevious(
2185
+ trackCount = tracks.size,
2186
+ currentIndex = currentTrackIndex,
2187
+ repeatMode = repeatModeAsSkipMode(),
2188
+ restartEnabled = skipRestartEnabled(),
2189
+ positionMs = serviceBinder?.engine?.currentPositionMs ?: 0L
2190
+ )
2191
+ when (action) {
2192
+ is QueueSkipArithmetic.SkipToPreviousAction.Previous -> {
2193
+ val prev = action.index
2194
+ currentTrackIndex = prev
2195
+ pendingTrackChangeReason = TrackChangeReason.USER_SKIP_PREVIOUS
2196
+ if (prev < p.mediaItemCount) {
2197
+ serviceBinder?.engine?.seekTo(prev, 0L)
2198
+ }
2199
+ rescheduleLookahead()
2200
+ recomputeCapabilities()
2201
+ }
2202
+ QueueSkipArithmetic.SkipToPreviousAction.RestartCurrent -> {
2203
+ // Restart the current track from 0 — a seek, not a track change.
2204
+ // A same-index seek emits no onMediaItemTransition, so no
2205
+ // onTrackChange; leave currentTrackIndex + pendingTrackChangeReason
2206
+ // untouched (stashing a reason no transition consumes would leak
2207
+ // into the next real skip).
2208
+ if (currentTrackIndex in 0 until p.mediaItemCount) {
2209
+ serviceBinder?.engine?.seekTo(currentTrackIndex, 0L)
2210
+ }
2211
+ }
2212
+ QueueSkipArithmetic.SkipToPreviousAction.NoOp -> return
2186
2213
  }
2187
- rescheduleLookahead()
2188
- recomputeCapabilities()
2189
2214
  }
2190
2215
 
2191
2216
  /**
@@ -2217,6 +2242,14 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2217
2242
  RepeatMode.QUEUE -> QueueSkipRepeatMode.QUEUE
2218
2243
  }
2219
2244
 
2245
+ /**
2246
+ * Whether skip-to-previous restarts the current track past the threshold
2247
+ * (`PlayerConfig.skipToPreviousBehavior == RESTART_OR_PREVIOUS`). Read live
2248
+ * from the current config at each skip + capability recompute.
2249
+ */
2250
+ private fun skipRestartEnabled(): Boolean =
2251
+ config.skipToPreviousBehavior == SkipToPreviousBehavior.RESTART_OR_PREVIOUS
2252
+
2220
2253
  /**
2221
2254
  * Recompute [cachedSkipCapability] from
2222
2255
  * authoritative state and fire [onSkipCapabilityChange] if either
@@ -2241,7 +2274,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2241
2274
  val mode = repeatModeAsSkipMode()
2242
2275
  val count = tracks.size
2243
2276
  val cur = currentTrackIndex
2244
- val newPrev = QueueSkipArithmetic.canSkipPrevious(count, cur, mode)
2277
+ val newPrev = QueueSkipArithmetic.canSkipPrevious(count, cur, mode, skipRestartEnabled())
2245
2278
  val newNext = QueueSkipArithmetic.canSkipNext(count, cur, mode)
2246
2279
  val current = cachedSkipCapability
2247
2280
  val capChanged =
@@ -2404,7 +2437,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2404
2437
  // playback continues from the current track / position. The
2405
2438
  // post-swap engine starts empty by construction.
2406
2439
  if (resumeIndex >= 0 && resumeIndex < tracks.count()) {
2407
- val media = MediaItemBuilder.buildMediaItems(service, tracks, lookaheadCache)
2440
+ val media = MediaItemBuilder.buildMediaItems(service, tracks, lookaheadCache, config.placeholderArtworkUri)
2408
2441
  newEngine.setMediaItems(
2409
2442
  items = media,
2410
2443
  startIndex = resumeIndex,
@@ -2806,7 +2839,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2806
2839
  retryAttemptsRemaining.clear()
2807
2840
  for (i in newTracks.indices) retryAttemptsRemaining[i] = attempts
2808
2841
 
2809
- val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache)
2842
+ val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
2810
2843
  serviceBinder?.engine?.setMediaItems(items, startIndex = 0, startPositionMs = 0L)
2811
2844
  p.prepare()
2812
2845
  serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
@@ -1,6 +1,7 @@
1
1
  package com.margelo.nitro.queueplayer
2
2
 
3
3
  import android.content.Context
4
+ import android.net.Uri
4
5
  import androidx.media3.common.C
5
6
  import androidx.media3.common.MediaMetadata
6
7
  import androidx.media3.common.util.UnstableApi
@@ -277,6 +278,60 @@ class MediaItemBuilderTest {
277
278
  assertEquals(expected, metadata.artworkUri?.toString())
278
279
  }
279
280
 
281
+ @Test
282
+ fun `MediaMetadata uses the custom placeholder URI when artworkUrl is null and a file URI is configured`() {
283
+ val customUri = "file:///data/user/0/com.example/files/logo.png"
284
+ val metadata = MediaItemBuilder.buildMediaItems(
285
+ context,
286
+ listOf(makeTrack(id = "t", url = "https://example.com/s.mp3")),
287
+ customPlaceholderUri = customUri
288
+ )[0].mediaMetadata
289
+ val expected = ArtworkContentProvider.mapFileUri(Uri.parse(customUri)).toString()
290
+ assertEquals(expected, metadata.artworkUri?.toString())
291
+ }
292
+
293
+ @Test
294
+ fun `MediaMetadata ignores a non-file custom placeholder URI and uses the built-in`() {
295
+ val metadata = MediaItemBuilder.buildMediaItems(
296
+ context,
297
+ listOf(makeTrack(id = "t", url = "https://example.com/s.mp3")),
298
+ customPlaceholderUri = "https://example.com/logo.png"
299
+ )[0].mediaMetadata
300
+ val expected =
301
+ "android.resource://${context.packageName}/${R.raw.rnqp_placeholder}"
302
+ assertEquals(expected, metadata.artworkUri?.toString())
303
+ }
304
+
305
+ @Test
306
+ fun `MediaMetadata prefers the real artworkUrl over a configured custom placeholder`() {
307
+ val metadata = MediaItemBuilder.buildMediaItems(
308
+ context,
309
+ listOf(
310
+ makeTrack(
311
+ id = "t",
312
+ url = "https://example.com/s.mp3",
313
+ artworkUrl = "https://example.com/cover.jpg"
314
+ )
315
+ ),
316
+ customPlaceholderUri = "file:///data/user/0/com.example/files/logo.png"
317
+ )[0].mediaMetadata
318
+ assertEquals("https://example.com/cover.jpg", metadata.artworkUri?.toString())
319
+ }
320
+
321
+ @Test
322
+ fun `MediaMetadata normalizes an uppercase FILE scheme custom placeholder to lowercase file`() {
323
+ val customUri = "FILE:///data/user/0/com.example/files/logo.png"
324
+ val metadata = MediaItemBuilder.buildMediaItems(
325
+ context,
326
+ listOf(makeTrack(id = "t", url = "https://example.com/s.mp3")),
327
+ customPlaceholderUri = customUri
328
+ )[0].mediaMetadata
329
+ val normalized = Uri.parse(customUri).buildUpon().scheme("file").build()
330
+ val expected = ArtworkContentProvider.mapFileUri(normalized).toString()
331
+ assertEquals(expected, metadata.artworkUri?.toString())
332
+ assertEquals("file", metadata.artworkUri?.scheme)
333
+ }
334
+
280
335
  @Test
281
336
  fun `MediaMetadata sets durationMs from positive seconds`() {
282
337
  val track = makeTrack(
@@ -0,0 +1,72 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import androidx.test.core.app.ApplicationProvider
6
+ import org.junit.After
7
+ import org.junit.Assert.assertNotNull
8
+ import org.junit.Assert.assertNull
9
+ import org.junit.Assert.assertSame
10
+ import org.junit.Test
11
+ import org.junit.runner.RunWith
12
+ import org.robolectric.RobolectricTestRunner
13
+ import org.robolectric.annotation.Config
14
+
15
+ /**
16
+ * Coverage for [PlaceholderArtwork.setCustom] — the consumer-supplied
17
+ * cover-art placeholder from `PlayerConfig.placeholderArtworkUri`. Only a
18
+ * local `file://` URI is honoured; a remote or unset URI leaves the
19
+ * bundled placeholder as the floor.
20
+ */
21
+ @RunWith(RobolectricTestRunner::class)
22
+ @Config(sdk = [34])
23
+ class PlaceholderArtworkTest {
24
+
25
+ private val context: Context
26
+ get() = ApplicationProvider.getApplicationContext()
27
+
28
+ @After
29
+ fun tearDown() {
30
+ // Process-wide singleton — reset so a set custom bitmap does not
31
+ // leak into other suites that read the built-in placeholder.
32
+ PlaceholderArtwork.setCustom(null)
33
+ }
34
+
35
+ @Test
36
+ fun `setCustom with a file URI installs a custom bitmap that bitmap returns`() {
37
+ PlaceholderArtwork.setCustom("file:///data/user/0/com.example/files/logo.png")
38
+ val custom = customBitmap()
39
+ assertNotNull(custom)
40
+ assertSame(custom, PlaceholderArtwork.bitmap(context))
41
+ }
42
+
43
+ @Test
44
+ fun `setCustom ignores a non-file URI and leaves the built-in placeholder`() {
45
+ PlaceholderArtwork.setCustom("https://example.com/logo.png")
46
+ assertNull(customBitmap())
47
+ }
48
+
49
+ @Test
50
+ fun `setCustom ignores an empty string and leaves the built-in placeholder`() {
51
+ PlaceholderArtwork.setCustom("")
52
+ assertNull(customBitmap())
53
+ }
54
+
55
+ @Test
56
+ fun `setCustom accepts an uppercase FILE scheme (case-insensitive)`() {
57
+ PlaceholderArtwork.setCustom("FILE:///data/user/0/com.example/files/logo.png")
58
+ assertNotNull(customBitmap())
59
+ }
60
+
61
+ @Test
62
+ fun `setCustom with null clears a previously-installed custom bitmap`() {
63
+ PlaceholderArtwork.setCustom("file:///data/user/0/com.example/files/logo.png")
64
+ PlaceholderArtwork.setCustom(null)
65
+ assertNull(customBitmap())
66
+ }
67
+
68
+ private fun customBitmap(): Bitmap? =
69
+ PlaceholderArtwork.javaClass.getDeclaredField("customBitmap")
70
+ .apply { isAccessible = true }
71
+ .get(PlaceholderArtwork) as Bitmap?
72
+ }