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.
- package/README.md +12 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +21 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderArtwork.kt +30 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoader.kt +9 -6
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +4 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +57 -10
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +53 -20
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +55 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderArtworkTest.kt +72 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoaderTest.kt +21 -9
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceArtworkLoaderTest.kt +9 -9
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/QueueSkipArithmeticTest.kt +103 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +3 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +7 -7
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +6 -6
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +41 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +34 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +1 -1
- package/ios/Cast/Core/CastNowPlayingController.swift +7 -4
- package/ios/NowPlayingInfo.swift +7 -9
- package/ios/PlaceholderArtwork.swift +24 -1
- package/ios/QueueSkipArithmetic.swift +55 -9
- package/ios/Tests/AVQueueBuilderTests.swift +9 -9
- package/ios/Tests/CastNowPlayingControllerTests.swift +33 -0
- package/ios/Tests/PlaceholderArtworkTests.swift +81 -0
- package/ios/Tests/SkipIndexTests.swift +97 -0
- package/ios/TrackPlayer.swift +42 -14
- package/lib/module/index.js.map +1 -1
- package/lib/module/types.js +9 -0
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts +7 -7
- package/lib/typescript/index.d.ts +1 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/types.d.ts +41 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JPlayerConfig.hpp +11 -1
- package/nitrogen/generated/android/c++/JSkipToPreviousBehavior.hpp +58 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +12 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/SkipToPreviousBehavior.kt +23 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +18 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/PlayerConfig.swift +30 -1
- package/nitrogen/generated/ios/swift/SkipToPreviousBehavior.swift +40 -0
- package/nitrogen/generated/shared/c++/PlayerConfig.hpp +12 -1
- package/nitrogen/generated/shared/c++/SkipToPreviousBehavior.hpp +76 -0
- package/package.json +1 -1
- package/src/TrackPlayer.nitro.ts +7 -7
- package/src/index.ts +1 -0
- 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
|
-
###
|
|
143
|
+
### Restart-or-previous skip-back (the "tap Previous restarts, then goes back" model)
|
|
144
144
|
|
|
145
|
-
The
|
|
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 >
|
|
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`
|
|
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(
|
|
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)
|
package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoader.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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).
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
2171
|
-
*
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2175
|
-
*
|
|
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
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
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
|
+
}
|