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
@@ -43,7 +43,6 @@ final class NowPlayingInfo {
43
43
  var snapshotProvider: (() -> Snapshot)?
44
44
 
45
45
  private var periodicTimer: Timer?
46
- private var cachedPlaceholderArtwork: MPMediaItemArtwork?
47
46
  /// Subscription to the global cast active-session router. When a
48
47
  /// remote session is active, `CastNowPlayingController` owns the
49
48
  /// lockscreen surface and this writer becomes a no-op — every
@@ -176,15 +175,14 @@ final class NowPlayingInfo {
176
175
  info[MPNowPlayingInfoPropertyPlaybackRate] = rate
177
176
  }
178
177
 
179
- /// Lazy MPMediaItemArtwork wrapper around the bundled placeholder
180
- /// UIImage. Built once per process; the closure that the system
181
- /// invokes for size requests just returns the same image (the system
182
- /// handles its own scaling).
178
+ /// MPMediaItemArtwork wrapper around the active placeholder image —
179
+ /// `PlaceholderArtwork.image` is the consumer-configured image when one
180
+ /// is set (`PlayerConfig.placeholderArtworkUri`), else the bundled one.
181
+ /// Rebuilt per call (cheap: the closure just returns the image, the
182
+ /// system handles scaling) so a placeholder set at `configure` is
183
+ /// reflected rather than serving a stale wrapper.
183
184
  private func placeholderArtwork() -> MPMediaItemArtwork {
184
- if let cached = cachedPlaceholderArtwork { return cached }
185
185
  let image = PlaceholderArtwork.image
186
- let mp = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
187
- cachedPlaceholderArtwork = mp
188
- return mp
186
+ return MPMediaItemArtwork(boundsSize: image.size) { _ in image }
189
187
  }
190
188
  }
@@ -15,7 +15,30 @@ import UIKit
15
15
  /// Android skins (mirror that defence on iOS too).
16
16
  enum PlaceholderArtwork {
17
17
 
18
- static let image: UIImage = loadImage()
18
+ /// The active placeholder: the consumer-supplied image when one is
19
+ /// configured + loadable, otherwise the bundled RNQP placeholder.
20
+ static var image: UIImage { customImage ?? bundledImage }
21
+
22
+ /// Set (or clear) the consumer-supplied placeholder from
23
+ /// `PlayerConfig.placeholderArtworkUri`. Loaded from a local `file://`
24
+ /// image the same way the bundled placeholder is (`UIImage(contentsOfFile:)`).
25
+ /// Only `file://` is honoured (scheme match is case-insensitive) — the
26
+ /// built-in is a local file, and a remote URL would break offline +
27
+ /// change behaviour; anything else (or an unloadable file) leaves the
28
+ /// bundled placeholder in effect. Decode is synchronous, so a very large
29
+ /// consumer image briefly blocks the `configure` call. Main-thread only
30
+ /// (called from `configure`).
31
+ static func setCustom(uri: String?) {
32
+ guard let uri = uri, !uri.isEmpty,
33
+ let url = URL(string: uri), url.scheme?.lowercased() == "file" else {
34
+ customImage = nil
35
+ return
36
+ }
37
+ customImage = UIImage(contentsOfFile: url.path)
38
+ }
39
+
40
+ private static var customImage: UIImage?
41
+ private static let bundledImage: UIImage = loadImage()
19
42
 
20
43
  private static func loadImage() -> UIImage {
21
44
  let frameworkBundle = Bundle(for: BundleToken.self)
@@ -48,9 +48,10 @@ public enum QueueSkipArithmetic {
48
48
 
49
49
  /// Previous-track index. Same early-bail rules as `computeNext`.
50
50
  ///
51
- /// `.off` and `.track` both return nil at index 0 (no previous
52
- /// track, no wrap). Apps that want the "tap Previous at track 0
53
- /// restarts current" UX implement that in their button handler.
51
+ /// `.off` and `.track` both return nil at index 0 (no previous track,
52
+ /// no wrap). The "restart current when past the threshold" model is
53
+ /// available via `PlayerConfig.skipToPreviousBehavior` (resolved by
54
+ /// `resolveSkipToPrevious`).
54
55
  public static func computePrevious(
55
56
  trackCount: Int,
56
57
  currentIndex: Int,
@@ -87,19 +88,64 @@ public enum QueueSkipArithmetic {
87
88
  /// `.off` and `.track` are false at index 0 (no wrap); `.queue` is
88
89
  /// always true with an active track (wraps).
89
90
  ///
90
- /// **Apps that want the Apple-Music-style "tap Previous restarts
91
- /// when position > 3s" UX** should implement that in their button
92
- /// handler: read playback position, branch to `seekTo(0)` vs
93
- /// `skipToPrevious()` themselves. Lib intentionally ships the
94
- /// primitive (skip if there's a track to skip to), not the policy.
91
+ /// When `restartEnabled` is true (`PlayerConfig.skipToPreviousBehavior
92
+ /// == .restartOrPrevious`), Previous is enabled whenever a track is
93
+ /// loaded even at index 0, where past the threshold it restarts the
94
+ /// current track so the control never greys out.
95
95
  public static func canSkipPrevious(
96
96
  trackCount: Int,
97
97
  currentIndex: Int,
98
- repeatMode: QueueSkipRepeatMode
98
+ repeatMode: QueueSkipRepeatMode,
99
+ restartEnabled: Bool = false
99
100
  ) -> Bool {
101
+ if restartEnabled { return trackCount > 0 }
100
102
  return computePrevious(
101
103
  trackCount: trackCount,
102
104
  currentIndex: currentIndex,
103
105
  repeatMode: repeatMode) != nil
104
106
  }
107
+
108
+ /// Position (ms) at or below which a `.restartOrPrevious` skip-back goes
109
+ /// to the previous track; past it, the current track restarts. Matches
110
+ /// Media3's default `maxSeekToPreviousPosition` (the industry-standard 3 s).
111
+ public static let restartThresholdMs: Int = 3000
112
+
113
+ /// What a skip-to-previous should do, resolved from queue position + the
114
+ /// configured behavior.
115
+ public enum SkipToPreviousAction: Equatable {
116
+ /// Navigate to this queue index (a real track change).
117
+ case previous(Int)
118
+ /// Restart the current track (seek to 0) — not a track change.
119
+ case restartCurrent
120
+ /// Nothing to do (empty queue).
121
+ case noOp
122
+ }
123
+
124
+ /// Resolves a skip-to-previous. When `restartEnabled` is false this is
125
+ /// exactly `computePrevious` (previous track, or nothing). When true it
126
+ /// applies the threshold model, matching Media3 `BasePlayer.seekToPrevious`:
127
+ /// a previous track exists AND `positionMs <= thresholdMs` → previous;
128
+ /// otherwise (past threshold, or first track with no previous) → restart
129
+ /// the current track.
130
+ public static func resolveSkipToPrevious(
131
+ trackCount: Int,
132
+ currentIndex: Int,
133
+ repeatMode: QueueSkipRepeatMode,
134
+ restartEnabled: Bool,
135
+ positionMs: Int,
136
+ thresholdMs: Int = restartThresholdMs
137
+ ) -> SkipToPreviousAction {
138
+ guard trackCount > 0, currentIndex >= 0 else { return .noOp }
139
+ let prev = computePrevious(
140
+ trackCount: trackCount,
141
+ currentIndex: currentIndex,
142
+ repeatMode: repeatMode)
143
+ if !restartEnabled {
144
+ return prev.map { .previous($0) } ?? .noOp
145
+ }
146
+ if let prev, positionMs <= thresholdMs {
147
+ return .previous(prev)
148
+ }
149
+ return .restartCurrent
150
+ }
105
151
  }
@@ -169,7 +169,7 @@ final class AVQueueBuilderTests: XCTestCase {
169
169
  let item = AVQueueBuilder.makeItem(
170
170
  for: track,
171
171
  queueItemId: UUID().uuidString,
172
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
172
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
173
173
  cache: cache
174
174
  )
175
175
  XCTAssertNotNil(item)
@@ -186,7 +186,7 @@ final class AVQueueBuilderTests: XCTestCase {
186
186
  func testMakeItemFallsBackToRemoteWhenCacheMisses() {
187
187
  // No cache provided → behaves identically to the no-cache path.
188
188
  let track = makeTrack(id: "t1", url: "https://example.com/track.m4a")
189
- let config = PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil)
189
+ let config = PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil)
190
190
  let item = AVQueueBuilder.makeItem(
191
191
  for: track, queueItemId: UUID().uuidString, config: config, cache: nil)
192
192
  XCTAssertNotNil(item)
@@ -214,7 +214,7 @@ final class AVQueueBuilderTests: XCTestCase {
214
214
  let item = AVQueueBuilder.makeItem(
215
215
  for: track,
216
216
  queueItemId: UUID().uuidString,
217
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
217
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
218
218
  cache: cache
219
219
  )
220
220
  XCTAssertNotNil(item)
@@ -260,7 +260,7 @@ final class AVQueueBuilderTests: XCTestCase {
260
260
  let item = AVQueueBuilder.makeItem(
261
261
  for: track,
262
262
  queueItemId: UUID().uuidString,
263
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
263
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
264
264
  cache: cache
265
265
  )
266
266
  XCTAssertNotNil(item)
@@ -296,7 +296,7 @@ final class AVQueueBuilderTests: XCTestCase {
296
296
  let items = AVQueueBuilder.buildPlayerItems(
297
297
  tracks: [trackA, trackB],
298
298
  queueItemIds: [UUID().uuidString, UUID().uuidString],
299
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
299
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
300
300
  cache: cache
301
301
  )
302
302
  XCTAssertEqual(items.count, 2)
@@ -371,12 +371,12 @@ final class AVQueueBuilderTests: XCTestCase {
371
371
  let queueItemIdB = UUID().uuidString
372
372
  let itemA = AVQueueBuilder.makeItem(
373
373
  for: track, queueItemId: queueItemIdA,
374
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
374
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
375
375
  cache: nil
376
376
  )
377
377
  let itemB = AVQueueBuilder.makeItem(
378
378
  for: track, queueItemId: queueItemIdB,
379
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
379
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
380
380
  cache: nil
381
381
  )
382
382
  XCTAssertNotNil(itemA)
@@ -401,7 +401,7 @@ final class AVQueueBuilderTests: XCTestCase {
401
401
  let queueItemId = UUID().uuidString
402
402
  let item = AVQueueBuilder.makeItem(
403
403
  for: track, queueItemId: queueItemId,
404
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
404
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
405
405
  cache: nil
406
406
  )
407
407
  XCTAssertNotNil(item)
@@ -417,7 +417,7 @@ final class AVQueueBuilderTests: XCTestCase {
417
417
  let queueItemId = UUID().uuidString
418
418
  let item = AVQueueBuilder.makeItem(
419
419
  for: track, queueItemId: queueItemId,
420
- config: PlayerConfig(httpHeaders: nil, userAgent: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
420
+ config: PlayerConfig(httpHeaders: nil, userAgent: nil, placeholderArtworkUri: nil, autoRetries: nil, retryBackoffMs: nil, networkTimeoutMs: nil, audioContentType: nil, audioCategoryOptions: nil, visualizationEnabled: nil, clampSeekToBuffered: nil, skipToPreviousBehavior: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil, progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil),
421
421
  cache: nil
422
422
  )
423
423
  XCTAssertNotNil(item)
@@ -132,6 +132,39 @@ final class CastNowPlayingControllerTests: XCTestCase {
132
132
  "activation renders the most recent cached metadata"
133
133
  )
134
134
  }
135
+
136
+ func testSeededPlaceholderReflectsConfiguredCustomImage() throws {
137
+ let tmp = FileManager.default.temporaryDirectory
138
+ .appendingPathComponent("rnqp-cast-placeholder-\(UUID().uuidString).jpg")
139
+ try makeTinyJpeg().write(to: tmp)
140
+ defer {
141
+ try? FileManager.default.removeItem(at: tmp)
142
+ PlaceholderArtwork.setCustom(uri: nil)
143
+ }
144
+ let builtInSize = PlaceholderArtwork.image.size
145
+ PlaceholderArtwork.setCustom(uri: tmp.absoluteString)
146
+ let customSize = PlaceholderArtwork.image.size
147
+ // Precondition: the fixture is a different size from the built-in,
148
+ // else the size check below can't tell them apart.
149
+ XCTAssertNotEqual(customSize, builtInSize)
150
+
151
+ try router.setActive(ControllableRemoteSession())
152
+
153
+ let artwork = info?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork
154
+ XCTAssertEqual(
155
+ artwork?.image(at: customSize)?.size, customSize,
156
+ "the cast lockscreen seeds the live custom placeholder, not a frozen built-in"
157
+ )
158
+ }
159
+
160
+ private func makeTinyJpeg() -> Data {
161
+ let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1))
162
+ let image = renderer.image { ctx in
163
+ UIColor.green.setFill()
164
+ ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
165
+ }
166
+ return image.jpegData(compressionQuality: 1.0) ?? Data()
167
+ }
135
168
  }
136
169
 
137
170
  // MARK: - Listener-firing fake
@@ -0,0 +1,81 @@
1
+ import XCTest
2
+ @testable import QueuePlayer
3
+ import UIKit
4
+
5
+ /// `PlaceholderArtwork` swaps in a consumer-supplied cover-art placeholder
6
+ /// (`PlayerConfig.placeholderArtworkUri`) in place of the bundled RNQP
7
+ /// image. Only a local `file://` image is honoured; a remote or unset URI,
8
+ /// or an unloadable file, keeps the bundled placeholder.
9
+ @MainActor
10
+ final class PlaceholderArtworkTests: XCTestCase {
11
+
12
+ override func tearDown() {
13
+ // Process-wide singleton — reset so a set custom image does not leak
14
+ // into other suites that read the bundled placeholder.
15
+ PlaceholderArtwork.setCustom(uri: nil)
16
+ super.tearDown()
17
+ }
18
+
19
+ func testCustomFileUriReplacesTheBundledPlaceholder() throws {
20
+ let bundled = PlaceholderArtwork.image
21
+ let tmp = FileManager.default.temporaryDirectory
22
+ .appendingPathComponent("rnqp-placeholder-\(UUID().uuidString).jpg")
23
+ try makeJpegBytes().write(to: tmp)
24
+ defer { try? FileManager.default.removeItem(at: tmp) }
25
+
26
+ PlaceholderArtwork.setCustom(uri: tmp.absoluteString)
27
+
28
+ let image = PlaceholderArtwork.image
29
+ XCTAssertFalse(image === bundled,
30
+ "a loadable file:// image replaces the bundled placeholder")
31
+ XCTAssertTrue(image === PlaceholderArtwork.image,
32
+ "the custom image is stable across reads")
33
+ }
34
+
35
+ func testUnsetUriKeepsTheBundledPlaceholder() {
36
+ let bundled = PlaceholderArtwork.image
37
+ PlaceholderArtwork.setCustom(uri: nil)
38
+ XCTAssertTrue(PlaceholderArtwork.image === bundled)
39
+ }
40
+
41
+ func testNonFileUriIsRejectedAndKeepsTheBundledPlaceholder() {
42
+ let bundled = PlaceholderArtwork.image
43
+ PlaceholderArtwork.setCustom(uri: "https://example.com/logo.png")
44
+ XCTAssertTrue(PlaceholderArtwork.image === bundled,
45
+ "a remote URI is rejected — offline-only file:// contract")
46
+ }
47
+
48
+ func testUnloadableFileUriFallsBackToTheBundledPlaceholder() {
49
+ let bundled = PlaceholderArtwork.image
50
+ PlaceholderArtwork.setCustom(uri: "file:///nonexistent/path/missing.jpg")
51
+ XCTAssertTrue(PlaceholderArtwork.image === bundled)
52
+ }
53
+
54
+ func testEmptyUriKeepsTheBundledPlaceholder() {
55
+ let bundled = PlaceholderArtwork.image
56
+ PlaceholderArtwork.setCustom(uri: "")
57
+ XCTAssertTrue(PlaceholderArtwork.image === bundled)
58
+ }
59
+
60
+ func testUppercaseFileSchemeIsHonoured() throws {
61
+ let bundled = PlaceholderArtwork.image
62
+ let tmp = FileManager.default.temporaryDirectory
63
+ .appendingPathComponent("rnqp-placeholder-\(UUID().uuidString).jpg")
64
+ try makeJpegBytes().write(to: tmp)
65
+ defer { try? FileManager.default.removeItem(at: tmp) }
66
+
67
+ PlaceholderArtwork.setCustom(uri: "FILE://" + tmp.path)
68
+
69
+ XCTAssertFalse(PlaceholderArtwork.image === bundled,
70
+ "an uppercase FILE:// scheme is honoured (case-insensitive)")
71
+ }
72
+
73
+ private func makeJpegBytes() -> Data {
74
+ let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1))
75
+ let image = renderer.image { ctx in
76
+ UIColor.blue.setFill()
77
+ ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
78
+ }
79
+ return image.jpegData(compressionQuality: 1.0) ?? Data()
80
+ }
81
+ }
@@ -256,4 +256,101 @@ final class SkipIndexTests: XCTestCase {
256
256
  XCTAssertFalse(QueueSkipArithmetic.canSkipPrevious(
257
257
  trackCount: 1, currentIndex: 0, repeatMode: .track))
258
258
  }
259
+
260
+ // MARK: - resolveSkipToPrevious — mode OFF (position ignored, current behavior)
261
+
262
+ private func resolve(
263
+ count: Int = 3, index: Int, mode: QueueSkipRepeatMode,
264
+ restart: Bool, positionMs: Int
265
+ ) -> QueueSkipArithmetic.SkipToPreviousAction {
266
+ QueueSkipArithmetic.resolveSkipToPrevious(
267
+ trackCount: count, currentIndex: index, repeatMode: mode,
268
+ restartEnabled: restart, positionMs: positionMs)
269
+ }
270
+
271
+ func testResolveModeOffIgnoresPositionAndGoesPrevious() {
272
+ // Past-threshold position must NOT restart when the option is off.
273
+ XCTAssertEqual(resolve(index: 2, mode: .off, restart: false, positionMs: 99_000),
274
+ .previous(1))
275
+ }
276
+
277
+ func testResolveModeOffFirstTrackIsNoOp() {
278
+ XCTAssertEqual(resolve(index: 0, mode: .off, restart: false, positionMs: 99_000), .noOp)
279
+ }
280
+
281
+ func testResolveModeOffQueueWraps() {
282
+ XCTAssertEqual(resolve(index: 0, mode: .queue, restart: false, positionMs: 0), .previous(2))
283
+ }
284
+
285
+ func testResolveModeOffEmptyQueueIsNoOp() {
286
+ XCTAssertEqual(resolve(count: 0, index: -1, mode: .off, restart: false, positionMs: 0), .noOp)
287
+ }
288
+
289
+ // MARK: - resolveSkipToPrevious — mode ON (3s threshold model)
290
+
291
+ func testResolvePastThresholdRestartsEvenWithPrevious() {
292
+ for pos in [3001, 5000, 600_000] {
293
+ XCTAssertEqual(resolve(index: 2, mode: .off, restart: true, positionMs: pos),
294
+ .restartCurrent, "position \(pos) > 3000 should restart")
295
+ }
296
+ }
297
+
298
+ func testResolveThresholdBoundaryGoesPrevious() {
299
+ // <= threshold → previous; one ms past → restart.
300
+ XCTAssertEqual(resolve(index: 2, mode: .off, restart: true, positionMs: 3000), .previous(1))
301
+ XCTAssertEqual(resolve(index: 2, mode: .off, restart: true, positionMs: 3001), .restartCurrent)
302
+ }
303
+
304
+ func testResolveUnderThresholdGoesPrevious() {
305
+ for pos in [0, 1, 2999] {
306
+ XCTAssertEqual(resolve(index: 2, mode: .off, restart: true, positionMs: pos),
307
+ .previous(1), "position \(pos) <= 3000 should go previous")
308
+ }
309
+ }
310
+
311
+ func testResolveFirstTrackUnderThresholdRestartsWhenNoPrevious() {
312
+ XCTAssertEqual(resolve(index: 0, mode: .off, restart: true, positionMs: 1000), .restartCurrent)
313
+ XCTAssertEqual(resolve(index: 0, mode: .track, restart: true, positionMs: 1000), .restartCurrent)
314
+ }
315
+
316
+ func testResolveFirstTrackUnderThresholdQueueWraps() {
317
+ // queue mode has a previous (wraps), so under threshold goes to the last track.
318
+ XCTAssertEqual(resolve(index: 0, mode: .queue, restart: true, positionMs: 1000), .previous(2))
319
+ }
320
+
321
+ func testResolveFirstTrackPastThresholdRestarts() {
322
+ for mode in [QueueSkipRepeatMode.off, .track, .queue] {
323
+ XCTAssertEqual(resolve(index: 0, mode: mode, restart: true, positionMs: 9000),
324
+ .restartCurrent, "first track past threshold restarts under \(mode)")
325
+ }
326
+ }
327
+
328
+ func testResolveModeOnEmptyQueueIsNoOp() {
329
+ XCTAssertEqual(resolve(count: 0, index: -1, mode: .off, restart: true, positionMs: 9000), .noOp)
330
+ }
331
+
332
+ // MARK: - canSkipPrevious with restartEnabled (forced true)
333
+
334
+ func testCanSkipPreviousForcedTrueAtEveryIndexAndMode() {
335
+ for mode in [QueueSkipRepeatMode.off, .track, .queue] {
336
+ for index in 0..<3 {
337
+ XCTAssertTrue(QueueSkipArithmetic.canSkipPrevious(
338
+ trackCount: 3, currentIndex: index, repeatMode: mode, restartEnabled: true),
339
+ "restart mode forces canSkipPrevious true at index \(index) under \(mode)")
340
+ }
341
+ }
342
+ }
343
+
344
+ func testCanSkipPreviousRestartEnabledFalseForEmptyQueue() {
345
+ XCTAssertFalse(QueueSkipArithmetic.canSkipPrevious(
346
+ trackCount: 0, currentIndex: -1, repeatMode: .off, restartEnabled: true))
347
+ }
348
+
349
+ func testCanSkipPreviousRestartDisabledMatchesExisting() {
350
+ // Default restartEnabled=false path is unchanged: false at index 0 under .off.
351
+ XCTAssertFalse(QueueSkipArithmetic.canSkipPrevious(
352
+ trackCount: 3, currentIndex: 0, repeatMode: .off, restartEnabled: false))
353
+ XCTAssertTrue(QueueSkipArithmetic.canSkipPrevious(
354
+ trackCount: 3, currentIndex: 1, repeatMode: .off, restartEnabled: false))
355
+ }
259
356
  }
@@ -97,14 +97,14 @@ class TrackPlayer: HybridTrackPlayerSpec {
97
97
  PlayerConfig(
98
98
  httpHeaders: nil,
99
99
  userAgent: nil,
100
- autoRetries: nil,
100
+ placeholderArtworkUri: nil, autoRetries: nil,
101
101
  retryBackoffMs: nil,
102
102
  networkTimeoutMs: nil,
103
103
  audioContentType: nil,
104
104
  audioCategoryOptions: nil,
105
105
  visualizationEnabled: nil,
106
106
  clampSeekToBuffered: nil,
107
- lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil,
107
+ skipToPreviousBehavior: nil, lookaheadCacheMaxSizeMb: nil, lookaheadCacheEvictionPolicy: nil,
108
108
  progressUpdateIntervalMs: nil, backgroundProgressUpdateIntervalMs: nil
109
109
  )
110
110
 
@@ -609,6 +609,10 @@ class TrackPlayer: HybridTrackPlayerSpec {
609
609
  // each track-change resolve.
610
610
  self.artworkResolver.configHeaders = config.httpHeaders
611
611
  self.artworkResolver.configUserAgent = config.userAgent
612
+ // Swap the cover-art placeholder to the consumer-supplied image
613
+ // (or back to the built-in when unset). Every placeholder site
614
+ // reads `PlaceholderArtwork.image`, so this is the only hook needed.
615
+ PlaceholderArtwork.setCustom(uri: config.placeholderArtworkUri)
612
616
  // Same live config for the cast lockscreen's own artwork
613
617
  // resolver so authenticated covers resolve during cast.
614
618
  CastNowPlayingController.shared.configureArtwork(
@@ -2115,21 +2119,37 @@ class TrackPlayer: HybridTrackPlayerSpec {
2115
2119
  }
2116
2120
  }
2117
2121
 
2118
- /// Standard skip-previous: go to the previous track if there is one
2119
- /// (per the arithmetic helper; `.off`/`.track` don't wrap, `.queue`
2120
- /// wraps). Under `.track` this navigates like `.off` and repeat-one
2121
- /// persists. Apps that want the Apple-Music-style "tap Previous
2122
- /// restarts when position > 3s" UX implement that in their button
2123
- /// handler the lib provides the primitive (skip if there's a
2124
- /// track to skip to), not the policy.
2122
+ /// Skip-previous, resolved by `QueueSkipArithmetic.resolveSkipToPrevious`
2123
+ /// from `PlayerConfig.skipToPreviousBehavior`. With the default `.previous`
2124
+ /// this goes to the previous track if there is one (`.off`/`.track` don't
2125
+ /// wrap, `.queue` wraps; `.track` navigates like `.off` and repeat-one
2126
+ /// persists). With `.restartOrPrevious` it restarts the current track (seek
2127
+ /// to 0) when past the threshold, otherwise goes to the previous track.
2125
2128
  func skipToPrevious() throws -> Promise<Void> {
2126
2129
  return Promise<Void>.async {
2127
2130
  if CastTransportRouter.routeSkipToPrevious() { return }
2128
2131
  await self.onMain {
2129
- guard self.engine != nil else { return }
2130
- guard let prev = self.computePreviousIndex() else { return }
2131
- self.applyNewCurrentIndex(
2132
- newIndex: prev, reason: .userSkipPrevious)
2132
+ guard let engine = self.engine else { return }
2133
+ let positionMs = Int(max(0, engine.currentPositionSeconds) * 1000)
2134
+ let action = QueueSkipArithmetic.resolveSkipToPrevious(
2135
+ trackCount: self.tracks.count,
2136
+ currentIndex: self.currentTrackIndex,
2137
+ repeatMode: self.repeatModeAsSkipMode(),
2138
+ restartEnabled: self.skipRestartEnabled(),
2139
+ positionMs: positionMs)
2140
+ switch action {
2141
+ case .previous(let idx):
2142
+ self.applyNewCurrentIndex(newIndex: idx, reason: .userSkipPrevious)
2143
+ case .restartCurrent:
2144
+ // Restart the current track from 0 — a seek, not a track change.
2145
+ // Same-index `applyNewCurrentIndex` takes its restart branch
2146
+ // (currentItem unchanged → no `onTrackChange`), engine-aware across
2147
+ // gapless (live / drained-rebuild) + crossfade.
2148
+ self.applyNewCurrentIndex(
2149
+ newIndex: self.currentTrackIndex, reason: .userSkipPrevious)
2150
+ case .noOp:
2151
+ return
2152
+ }
2133
2153
  }
2134
2154
  }
2135
2155
  }
@@ -2156,6 +2176,13 @@ class TrackPlayer: HybridTrackPlayerSpec {
2156
2176
  /// Map the Nitrogen-generated `RepeatMode` enum onto the pure-Swift
2157
2177
  /// `QueueSkipRepeatMode` so `QueueSkipArithmetic` can stay off the
2158
2178
  /// Swift ↔ C++ interop path.
2179
+ /// Whether skip-to-previous restarts the current track past the threshold
2180
+ /// (`PlayerConfig.skipToPreviousBehavior == .restartOrPrevious`). Read live
2181
+ /// from the current config at each skip + capability recompute.
2182
+ private func skipRestartEnabled() -> Bool {
2183
+ return self.config.skipToPreviousBehavior == .restartOrPrevious
2184
+ }
2185
+
2159
2186
  private func repeatModeAsSkipMode() -> QueueSkipRepeatMode {
2160
2187
  switch self.repeatModeState {
2161
2188
  case .off: return .off
@@ -2203,7 +2230,8 @@ class TrackPlayer: HybridTrackPlayerSpec {
2203
2230
  let count = tracks.count
2204
2231
  let cur = currentTrackIndex
2205
2232
  let newPrev = QueueSkipArithmetic.canSkipPrevious(
2206
- trackCount: count, currentIndex: cur, repeatMode: mode)
2233
+ trackCount: count, currentIndex: cur, repeatMode: mode,
2234
+ restartEnabled: skipRestartEnabled())
2207
2235
  let newNext = QueueSkipArithmetic.canSkipNext(
2208
2236
  trackCount: count, currentIndex: cur, repeatMode: mode)
2209
2237
  let current = cachedSkipCapability
@@ -1 +1 @@
1
- {"version":3,"names":["getCastManager","getEqualizer","getTrackPlayer","getVisualizer","registerPlaybackService","unregisterPlaybackService","crossfadeDurationBounds","SectionIcon","EQ_BAND_COUNT","EQ_BAND_FREQUENCIES_HZ","EQ_FLAT_PRESET_NAME","EQ_GAIN_MAX_DB","EQ_GAIN_MIN_DB","clampGain","gainsFromBands","gainsFromMap","isBuiltinPreset","nearestBandIndex","Cast"],"sourceRoot":"../../src","sources":["index.ts"],"mappings":";;AAAA;;AAMA;;AAuEA;AACA;AACA;AACA,SAASA,cAAc,EAAEC,YAAY,EAAEC,cAAc,EAAEC,aAAa,QAAQ,cAAW;;AAEvF;AACA;AACA;AACA,SACEC,uBAAuB,EACvBC,yBAAyB,QACpB,sBAAmB;AAM1B;AACA;AACA,SAASC,uBAAuB,QAAQ,YAAS;;AAEjD;AACA;AACA;AACA,SAASC,WAAW,QAAQ,YAAS;;AAErC;AACA;AACA;AACA,SACEC,aAAa,EACbC,sBAAsB,EACtBC,mBAAmB,EACnBC,cAAc,EACdC,cAAc,EACdC,SAAS,EACTC,cAAc,EACdC,YAAY,EACZC,eAAe,EACfC,gBAAgB,QACX,gBAAa;;AAEpB;AACA,cAAc,kBAAS;;AAEvB;AACA;AACA;AACA,OAAO,KAAKC,IAAI,MAAM,iBAAQ","ignoreList":[]}
1
+ {"version":3,"names":["getCastManager","getEqualizer","getTrackPlayer","getVisualizer","registerPlaybackService","unregisterPlaybackService","crossfadeDurationBounds","SectionIcon","EQ_BAND_COUNT","EQ_BAND_FREQUENCIES_HZ","EQ_FLAT_PRESET_NAME","EQ_GAIN_MAX_DB","EQ_GAIN_MIN_DB","clampGain","gainsFromBands","gainsFromMap","isBuiltinPreset","nearestBandIndex","Cast"],"sourceRoot":"../../src","sources":["index.ts"],"mappings":";;AAAA;;AAMA;;AAwEA;AACA;AACA;AACA,SAASA,cAAc,EAAEC,YAAY,EAAEC,cAAc,EAAEC,aAAa,QAAQ,cAAW;;AAEvF;AACA;AACA;AACA,SACEC,uBAAuB,EACvBC,yBAAyB,QACpB,sBAAmB;AAM1B;AACA;AACA,SAASC,uBAAuB,QAAQ,YAAS;;AAEjD;AACA;AACA;AACA,SAASC,WAAW,QAAQ,YAAS;;AAErC;AACA;AACA;AACA,SACEC,aAAa,EACbC,sBAAsB,EACtBC,mBAAmB,EACnBC,cAAc,EACdC,cAAc,EACdC,SAAS,EACTC,cAAc,EACdC,YAAY,EACZC,eAAe,EACfC,gBAAgB,QACX,gBAAa;;AAEpB;AACA,cAAc,kBAAS;;AAEvB;AACA;AACA;AACA,OAAO,KAAKC,IAAI,MAAM,iBAAQ","ignoreList":[]}
@@ -99,6 +99,15 @@ export const crossfadeDurationBounds = {
99
99
  * one that loops.
100
100
  */
101
101
 
102
+ /**
103
+ * How `skipToPrevious` (and the OS Previous control) behaves.
104
+ * - `'previous'` — always go to the previous track (the default; current
105
+ * behavior).
106
+ * - `'restart-or-previous'` — restart the current track (seek to 0) when it
107
+ * is past ~3 s, otherwise go to the previous track. The standard
108
+ * music-player model. See `PlayerConfig.skipToPreviousBehavior`.
109
+ */
110
+
102
111
  /**
103
112
  * How the lock-screen / notification skip controls behave: skip between queue
104
113
  * tracks (`'track'`) or jump forward/back within the current track by a fixed
@@ -1 +1 @@
1
- {"version":3,"names":["crossfadeDurationBounds","minMs","maxMs","stepMs","SectionIcon"],"sourceRoot":"../../src","sources":["types.ts"],"mappings":";;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AA2BA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAmJA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;;AA+BA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;;AAyBA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMA,uBAAuB,GAAG;EACrC;EACAC,KAAK,EAAE,KAAK;EACZ;EACAC,KAAK,EAAE,MAAM;EACb;EACAC,MAAM,EAAE;AACV,CAAU;;AAEV;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;;AAUA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAYA;AACA;AACA;AACA;AACA;AACA;;AA0BA;AACA;AACA;AACA;;AAOe;;AAEf;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAUA;AACA;AACA;AACA;AACA;;AAWA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AA6EA;;AAMA;;AAcA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AA+DA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAQA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAgBA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AA0BA;AACA;AACA;;AASA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;;AAMA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAMA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AA4FA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,WAAYC,WAAW,0BAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAA,OAAXA,WAAW;AAAA;;AAgBvB;AACA;AACA;AACA;AACA;AACA;AACA;;AAuBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAOA;AACA;AACA;;AAcA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAuDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAQA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;;AASA;AACA;AACA;AACA;;AAWA;AACA;AACA;;AAGA;AACA;AACA;AACA;;AAYA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAaA;AACA;AACA;AACA;AACA;;AA0BA;AACA;AACA;AACA;AACA;;AAKA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAMA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAgBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","ignoreList":[]}
1
+ {"version":3,"names":["crossfadeDurationBounds","minMs","maxMs","stepMs","SectionIcon"],"sourceRoot":"../../src","sources":["types.ts"],"mappings":";;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AA2BA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAmLA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;;AA+BA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;;AAyBA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMA,uBAAuB,GAAG;EACrC;EACAC,KAAK,EAAE,KAAK;EACZ;EACAC,KAAK,EAAE,MAAM;EACb;EACAC,MAAM,EAAE;AACV,CAAU;;AAEV;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;;AAUA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAYA;AACA;AACA;AACA;AACA;AACA;;AA0BA;AACA;AACA;AACA;;AAOe;;AAEf;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAUA;AACA;AACA;AACA;AACA;;AAWA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AA6EA;;AAMA;;AAcA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AA+DA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAQA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAgBA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AA0BA;AACA;AACA;;AASA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;;AAMA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAMA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AA4FA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,WAAYC,WAAW,0BAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAXA,WAAW;EAAA,OAAXA,WAAW;AAAA;;AAgBvB;AACA;AACA;AACA;AACA;AACA;AACA;;AAuBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAOA;AACA;AACA;;AAcA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAuDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAQA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAGA;;AASA;AACA;AACA;AACA;;AAWA;AACA;AACA;;AAGA;AACA;AACA;AACA;;AAYA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAaA;AACA;AACA;AACA;AACA;;AA0BA;AACA;AACA;AACA;AACA;;AAKA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAMA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAgBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","ignoreList":[]}
@@ -266,17 +266,17 @@ export interface TrackPlayer extends HybridObject<{
266
266
  getCanSkipNext(): boolean;
267
267
  /**
268
268
  * Whether the user-facing Previous button should be enabled.
269
- * Symmetric with `getCanSkipNext` — true when there's a previous
269
+ * Symmetric with `getCanSkipNext` — with the default
270
+ * `skipToPreviousBehavior: 'previous'`, true when there's a previous
270
271
  * track to skip to under the current repeat mode:
271
272
  * - `'off'` and `'track'` at index 0: false (no previous, no wrap)
272
273
  * - `'queue'`: true with an active track (wraps)
273
274
  *
274
- * **Apps that want the Apple-Music-style "tap Previous restarts
275
- * current when position > 3s" UX** should implement that
276
- * themselves in their button handler branch on the live
277
- * `useProgress().position` value to either `seekTo(0)` or
278
- * `skipToPrevious()`. The lib intentionally ships the primitive,
279
- * not the policy.
275
+ * With `PlayerConfig.skipToPreviousBehavior: 'restart-or-previous'` the
276
+ * lib applies the standard music-player "restart when past ~3 s, else
277
+ * previous" model natively, and this reports `true` whenever a track is
278
+ * loaded (even at index 0, where past the threshold Previous restarts the
279
+ * current track) so the control never greys out.
280
280
  */
281
281
  getCanSkipPrevious(): boolean;
282
282
  /**
@@ -2,7 +2,7 @@ export type { TrackPlayer } from './TrackPlayer.nitro';
2
2
  export type { Equalizer } from './Equalizer.nitro';
3
3
  export type { Visualizer } from './Visualizer.nitro';
4
4
  export type { CastManager } from './CastManager.nitro';
5
- export type { TrackItem, PlayerConfig, PlayerState, BufferState, PlaybackMode, PlaybackModeKind, RepeatMode, RemoteSkipMode, RemoteControlOptions, StateChangeReason, TrackChangeReason, QueueChangeReason, TrackSource, PlayerProgress, PlaybackError, PlaybackErrorCode, ShuffleResult, SkipCapability, SleepTimerState, NowPlayingFormat, AudioCodec, AudioContainer, LookaheadCacheConfig, EvictionPolicy, CacheStatus, MediaSearchRequest, MediaSearchType, MediaSearchOrigin, MediaSearchReference, MediaSearchRepeatMode, MediaSearchReleaseDateRange, MediaSearchQueueLocation, BrowseItem, BrowseSection, BrowseSnapshot, ServiceReadyReason, EqualizerBand, EqualizerPreset, ReplayGainMode, PitchCorrectionMode, VisualizerConfig, VisualizerFftSize, VisualizerFrame, VisualizerErrorReason, CastProtocol, CastStreamingModel, CastDeviceKind, CastSessionEndReason, CastLocalNetworkPermission, CastReceiverCapabilities, CastReceiver, CastRoute, CastDiscoveryState, CastSessionDiedEvent, CastLocalNetworkPermissionEvent, CastConnectOptions, AudioRouteKind, AudioRoute, } from './types';
5
+ export type { TrackItem, PlayerConfig, PlayerState, BufferState, PlaybackMode, PlaybackModeKind, RepeatMode, SkipToPreviousBehavior, RemoteSkipMode, RemoteControlOptions, StateChangeReason, TrackChangeReason, QueueChangeReason, TrackSource, PlayerProgress, PlaybackError, PlaybackErrorCode, ShuffleResult, SkipCapability, SleepTimerState, NowPlayingFormat, AudioCodec, AudioContainer, LookaheadCacheConfig, EvictionPolicy, CacheStatus, MediaSearchRequest, MediaSearchType, MediaSearchOrigin, MediaSearchReference, MediaSearchRepeatMode, MediaSearchReleaseDateRange, MediaSearchQueueLocation, BrowseItem, BrowseSection, BrowseSnapshot, ServiceReadyReason, EqualizerBand, EqualizerPreset, ReplayGainMode, PitchCorrectionMode, VisualizerConfig, VisualizerFftSize, VisualizerFrame, VisualizerErrorReason, CastProtocol, CastStreamingModel, CastDeviceKind, CastSessionEndReason, CastLocalNetworkPermission, CastReceiverCapabilities, CastReceiver, CastRoute, CastDiscoveryState, CastSessionDiedEvent, CastLocalNetworkPermissionEvent, CastConnectOptions, AudioRouteKind, AudioRoute, } from './types';
6
6
  export { getCastManager, getEqualizer, getTrackPlayer, getVisualizer } from './clients';
7
7
  export { registerPlaybackService, unregisterPlaybackService, } from './playbackService';
8
8
  export type { PlaybackServiceHandler, PlaybackServiceFactory, } from './playbackService';