react-native-audio-browser 0.2.0-next.12 → 0.2.0-next.14

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 (185) hide show
  1. package/README.md +10 -0
  2. package/android/build.gradle +20 -1
  3. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +8 -1
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +52 -0
  5. package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
  6. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +27 -11
  7. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +1 -1
  8. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +11 -0
  9. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +11 -1
  10. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
  11. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +21 -22
  12. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +17 -2
  13. package/android/src/main/java/com/audiobrowser/player/Player.kt +3 -0
  14. package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
  15. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +73 -0
  16. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +16 -6
  17. package/android/src/main/res/values/strings.xml +37 -2
  18. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +50 -5
  19. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +9 -0
  20. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +3 -2
  21. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
  22. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +56 -0
  23. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +4 -4
  24. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +77 -10
  25. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +2 -2
  26. package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
  27. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +10 -9
  28. package/ios/Browser/BrowserManager.swift +106 -45
  29. package/ios/Browser/InertStyleDiagnostic.swift +279 -0
  30. package/ios/Browser/JsonModels.swift +81 -15
  31. package/ios/Browser/SectionPresentation.swift +30 -0
  32. package/ios/Browser/StyleResolver.swift +11 -0
  33. package/ios/Browser/TabBarEntries.swift +7 -1
  34. package/ios/CarPlay/CarPlayController.swift +139 -35
  35. package/ios/CarPlay/CarPlayListItemFactory.swift +99 -27
  36. package/ios/Extension/Section+Copying.swift +1 -3
  37. package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
  38. package/ios/Http/HttpClient.swift +8 -2
  39. package/ios/HybridAudioBrowser.swift +56 -12
  40. package/ios/Model/NitroTypeStubs.swift +60 -1
  41. package/ios/Player/MediaIntentCriteria.swift +13 -5
  42. package/ios/Player/MediaLoader.swift +33 -9
  43. package/ios/Player/NowPlayingUpdater.swift +53 -5
  44. package/ios/Player/PlaybackCoordinator.swift +11 -4
  45. package/ios/Player/PlaybackTimer.swift +8 -2
  46. package/ios/Player/QueueManager.swift +36 -10
  47. package/ios/Player/ShuffleOrder.swift +23 -0
  48. package/ios/Player/SleepTimerHandling.swift +13 -0
  49. package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
  50. package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
  51. package/ios/Tests/JsonModelsTests.swift +76 -1
  52. package/ios/Tests/QueueManagerTests.swift +72 -6
  53. package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
  54. package/ios/Tests/ResolvedTrackCopyingTests.swift +2 -2
  55. package/ios/Tests/SectionPresentationTests.swift +29 -0
  56. package/ios/Tests/StyleResolverTests.swift +60 -0
  57. package/ios/Tests/Support/TestTypes.swift +7 -0
  58. package/ios/TrackPlayer.swift +57 -34
  59. package/ios/Util/NetworkMonitor.swift +43 -26
  60. package/ios/Util/SFSymbolRenderer.swift +24 -2
  61. package/ios/Util/SVGProcessor.swift +9 -2
  62. package/lib/commonjs/features/browser-config.js +1 -0
  63. package/lib/commonjs/features/browser-config.js.map +1 -1
  64. package/lib/commonjs/features/playback/progress.js +2 -1
  65. package/lib/commonjs/features/playback/progress.js.map +1 -1
  66. package/lib/commonjs/features/playback/rate.js +8 -1
  67. package/lib/commonjs/features/playback/rate.js.map +1 -1
  68. package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
  69. package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
  70. package/lib/module/features/browser-config.js +1 -0
  71. package/lib/module/features/browser-config.js.map +1 -1
  72. package/lib/module/features/playback/progress.js +2 -1
  73. package/lib/module/features/playback/progress.js.map +1 -1
  74. package/lib/module/features/playback/rate.js +8 -1
  75. package/lib/module/features/playback/rate.js.map +1 -1
  76. package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
  77. package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
  78. package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
  79. package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
  80. package/lib/typescript/src/features/playback/rate.d.ts +8 -1
  81. package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
  82. package/lib/typescript/src/types/browser-native.d.ts +2 -1
  83. package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
  84. package/lib/typescript/src/types/browser-nodes.d.ts +116 -9
  85. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  86. package/lib/typescript/src/types/browser.d.ts +36 -0
  87. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  88. package/nitrogen/generated/android/AudioBrowserOnLoad.cpp +2 -0
  89. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +4 -0
  90. package/nitrogen/generated/android/c++/JBrowseResult.hpp +6 -0
  91. package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
  92. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +4 -0
  93. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +4 -0
  94. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +4 -0
  95. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +4 -0
  96. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +4 -0
  97. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +4 -0
  98. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +4 -0
  99. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +4 -0
  100. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__string__.hpp +102 -0
  101. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +6 -0
  102. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +4 -0
  103. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +4 -0
  104. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +6 -0
  105. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +4 -0
  106. package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
  107. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +13 -0
  108. package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
  109. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +4 -0
  110. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +20 -1
  111. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +6 -0
  112. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +4 -0
  113. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +4 -0
  114. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +6 -0
  115. package/nitrogen/generated/android/c++/JSection.hpp +7 -5
  116. package/nitrogen/generated/android/c++/JSectionStyle.hpp +30 -3
  117. package/nitrogen/generated/android/c++/JTrack.hpp +4 -0
  118. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +4 -0
  119. package/nitrogen/generated/android/c++/JTrackStyle.hpp +24 -3
  120. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +6 -0
  121. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
  122. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Func_std__shared_ptr_Promise_std__string__.kt +80 -0
  123. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
  124. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
  125. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +7 -4
  126. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Section.kt +2 -5
  127. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +18 -3
  128. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +15 -3
  129. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.cpp +9 -0
  130. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +104 -0
  131. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +9 -0
  132. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +9 -0
  133. package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
  134. package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__string__.swift +54 -0
  135. package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
  136. package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
  137. package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +48 -1
  138. package/nitrogen/generated/ios/swift/Section.swift +1 -19
  139. package/nitrogen/generated/ios/swift/SectionStyle.swift +70 -1
  140. package/nitrogen/generated/ios/swift/TrackStyle.swift +59 -1
  141. package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
  142. package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
  143. package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
  144. package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +6 -2
  145. package/nitrogen/generated/shared/c++/Section.hpp +1 -5
  146. package/nitrogen/generated/shared/c++/SectionStyle.hpp +32 -2
  147. package/nitrogen/generated/shared/c++/TrackStyle.hpp +25 -2
  148. package/package.json +7 -2
  149. package/src/features/browser-config.ts +1 -0
  150. package/src/features/playback/progress.ts +2 -1
  151. package/src/features/playback/rate.ts +8 -1
  152. package/src/style-resolution.test.ts +72 -1
  153. package/src/types/browser-native.ts +5 -1
  154. package/src/types/browser-nodes.ts +124 -9
  155. package/src/types/browser.ts +38 -0
  156. package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
  157. package/src/web/TrackPlayer/QueueManager.ts +14 -0
  158. package/website/guide/android-auto.md +114 -0
  159. package/website/guide/android-certificates.md +409 -0
  160. package/website/guide/artwork.md +255 -0
  161. package/website/guide/audio-output.md +135 -0
  162. package/website/guide/automotive.md +86 -0
  163. package/website/guide/basic-usage.md +151 -0
  164. package/website/guide/battery.md +115 -0
  165. package/website/guide/browser.md +581 -0
  166. package/website/guide/carplay.md +620 -0
  167. package/website/guide/configuration.md +173 -0
  168. package/website/guide/equalizer.md +231 -0
  169. package/website/guide/errors.md +310 -0
  170. package/website/guide/favorites.md +154 -0
  171. package/website/guide/gate.md +143 -0
  172. package/website/guide/getting-started.md +46 -0
  173. package/website/guide/hooks.md +238 -0
  174. package/website/guide/live-streams.md +125 -0
  175. package/website/guide/metadata.md +239 -0
  176. package/website/guide/migrating-from-track-player.md +244 -0
  177. package/website/guide/native-callback-fetch.md +127 -0
  178. package/website/guide/network.md +86 -0
  179. package/website/guide/now-playing.md +175 -0
  180. package/website/guide/playback.md +328 -0
  181. package/website/guide/queue.md +300 -0
  182. package/website/guide/remote-controls.md +256 -0
  183. package/website/guide/search.md +255 -0
  184. package/website/guide/sleep-timer.md +268 -0
  185. package/website/guide/track.md +246 -0
@@ -30,17 +30,51 @@ final class NowPlayingUpdater {
30
30
  let album: String?
31
31
  }
32
32
 
33
+ /// Dedupe baseline for `applyFields`. Only meaningful while it describes the
34
+ /// controller's current info dict, so the two are cleared together in
35
+ /// `clearNowPlaying()` — never separately.
33
36
  private var lastPublished: Published?
34
37
 
38
+ /// Empties the now-playing surface and the dedupe baseline that describes it.
39
+ /// One call, because clearing the dict without the baseline makes the next
40
+ /// publish of the SAME track dedupe against a dict that no longer holds it —
41
+ /// artwork lands (never deduped) but the text doesn't, and `onChanged`
42
+ /// never fires.
43
+ func clearNowPlaying() {
44
+ nowPlayingInfoController.clear()
45
+ lastPublished = nil
46
+ formattedTrackId = nil
47
+ }
48
+
35
49
  /// Bumped on every `render`; an async formatter result applies only if its render is still the
36
50
  /// latest (latest-render-wins — drops a result whose track skipped or whose state moved on while
37
51
  /// the formatter was in flight).
38
52
  private var renderGeneration: UInt = 0
39
53
 
54
+ /// The track whose *formatted* line is currently on the surface. While this
55
+ /// matches the rendering track, `render` skips its interim default write — see
56
+ /// the comment at that site. Cleared whenever the surface stops showing a
57
+ /// formatter result (a flash overwrites it, `clearNowPlaying` empties it).
58
+ private var formattedTrackId: String?
59
+
40
60
  init(nowPlayingInfoController: NowPlayingInfoController) {
41
61
  self.nowPlayingInfoController = nowPlayingInfoController
42
62
  }
43
63
 
64
+ /// Pixel width to request now-playing artwork at: the active scene's screen
65
+ /// width in pixels, capped at `maxArtworkPixelWidth`. With no foreground
66
+ /// scene (headless CarPlay) the cap stands in — every current device exceeds
67
+ /// it anyway, and this only sizes a CDN request.
68
+ private static var artworkPixelWidth: CGFloat {
69
+ guard let screen = UIApplication.shared.activeWindowScene?.screen else {
70
+ return maxArtworkPixelWidth
71
+ }
72
+ return min(screen.bounds.width * screen.scale, maxArtworkPixelWidth)
73
+ }
74
+
75
+ /// Mirrored by the Kotlin twin's `artworkSizePx`.
76
+ private static let maxArtworkPixelWidth: CGFloat = 1200
77
+
44
78
  /// Publishes the static, per-track fields + artwork on a track change. The title/artist/album
45
79
  /// lines are owned by `render` (routed through `applyFields` here so it dedupes against later
46
80
  /// renders).
@@ -75,6 +109,8 @@ final class NowPlayingUpdater {
75
109
  let defaultSecondary = override?.artist ?? track.artist
76
110
  let defaultAlbum = override?.album ?? track.album
77
111
 
112
+ let trackId = track.identity ?? track.path
113
+
78
114
  if let flash {
79
115
  applyFields(
80
116
  track: track,
@@ -82,10 +118,22 @@ final class NowPlayingUpdater {
82
118
  artist: flash.artist ?? defaultSecondary,
83
119
  album: flash.album ?? defaultAlbum,
84
120
  )
121
+ // The flash replaced the formatter's line, so the surface no longer shows
122
+ // one: the render that follows the flash reverting must publish the default
123
+ // immediately rather than sit on stale flash text awaiting the formatter.
124
+ formattedTrackId = nil
85
125
  return
86
126
  }
87
127
 
88
- applyFields(track: track, title: defaultTitle, artist: defaultSecondary, album: defaultAlbum)
128
+ // Publish the raw default only while no formatter result is on screen for this
129
+ // track. `render` runs on EVERY playback transition (state change, playing/
130
+ // buffering flip, timed-metadata tick, connectivity change); writing the
131
+ // default each time would flash the track title over the formatter's line
132
+ // until the async result lands, since the two differ by construction on a live
133
+ // stream (station name vs. song line).
134
+ if formatter == nil || formattedTrackId != trackId {
135
+ applyFields(track: track, title: defaultTitle, artist: defaultSecondary, album: defaultAlbum)
136
+ }
89
137
 
90
138
  guard let formatter else { return }
91
139
  let params = FormatNowPlayingParams(
@@ -111,6 +159,9 @@ final class NowPlayingUpdater {
111
159
  artist: formatted.artist ?? defaultSecondary,
112
160
  album: formatted.album ?? defaultAlbum,
113
161
  )
162
+ // A formatter result now owns the surface for this track, so later
163
+ // renders skip the interim default write.
164
+ self.formattedTrackId = trackId
114
165
  }
115
166
  }
116
167
  .catch { @Sendable [weak self] error in
@@ -152,10 +203,7 @@ final class NowPlayingUpdater {
152
203
  let artworkUrl = track.artworkSource?.uri ?? track.artwork?.url
153
204
  logger.debug("loadArtwork: \(track.title), artworkUrl: \(artworkUrl ?? "nil")")
154
205
 
155
- // Now Playing artwork: use screen width in pixels, capped at 1200px
156
- let screenScale = UIScreen.main.scale
157
- let screenWidth = UIScreen.main.bounds.width * screenScale
158
- let artworkSize = min(screenWidth, 1200)
206
+ let artworkSize = Self.artworkPixelWidth
159
207
  let nowPlayingSize = ImageContext(width: artworkSize, height: artworkSize)
160
208
 
161
209
  artworkLoadTask?.cancel()
@@ -526,14 +526,21 @@ class PlaybackCoordinator {
526
526
  let changed = queue.add([track], initialIndex: 0)
527
527
  if changed { handleCurrentTrackChanged() }
528
528
  } else {
529
- queue.replace(queue.currentIndex, track)
530
- handleCurrentTrackChanged()
529
+ // `currentIndex` is invariantly -1 or in bounds, and -1 took the branch
530
+ // above, so this cannot fail — but surface it rather than reloading the
531
+ // old track if that invariant is ever broken.
532
+ do {
533
+ try queue.replace(queue.currentIndex, track)
534
+ handleCurrentTrackChanged()
535
+ } catch {
536
+ logger.error("load: replace at \(self.queue.currentIndex) failed: \(error)")
537
+ }
531
538
  }
532
539
  }
533
540
  }
534
541
 
535
- func replace(_ index: Int, _ track: Track) {
536
- queue.replace(index, track)
542
+ func replace(_ index: Int, _ track: Track) throws {
543
+ try queue.replace(index, track)
537
544
  }
538
545
 
539
546
  func setQueue(
@@ -48,10 +48,16 @@ final class PlaybackTimer {
48
48
 
49
49
  private func start(_ interval: TimeInterval) {
50
50
  guard timer == nil else { return }
51
- timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) {
52
- [weak self] _ in
51
+ // `.common`, not the `.default` mode `Timer.scheduledTimer` installs: UIKit
52
+ // runs the main run loop in tracking mode while a list is being dragged, and
53
+ // a default-mode timer does not fire there. Missed repeats are dropped rather
54
+ // than replayed, so progress events would freeze and interval ticks would be
55
+ // lost for as long as the user scrolls. (VolumeFader already does this.)
56
+ let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
53
57
  MainActor.assumeIsolated { self?.onTick() }
54
58
  }
59
+ self.timer = timer
60
+ RunLoop.main.add(timer, forMode: .common)
55
61
  }
56
62
 
57
63
  private func stop() {
@@ -225,11 +225,8 @@ class QueueManager {
225
225
  let wasEmpty = tracks.isEmpty
226
226
  let insertIndex = tracks.count
227
227
  tracks.append(contentsOf: newTracks)
228
+ if wasEmpty { return adoptInitialCurrent(initialIndex) }
228
229
  shuffleOrder.insert(at: insertIndex, count: newTracks.count)
229
- if wasEmpty {
230
- currentIndex = max(0, min(initialIndex, tracks.count - 1))
231
- return true
232
- }
233
230
  return false
234
231
  }
235
232
 
@@ -250,14 +247,31 @@ class QueueManager {
250
247
  currentIndex += newTracks.count
251
248
  }
252
249
  tracks.insert(contentsOf: newTracks, at: index)
250
+ if wasEmpty { return adoptInitialCurrent(0) }
253
251
  shuffleOrder.insert(at: index, count: newTracks.count)
254
- if wasEmpty {
255
- currentIndex = 0
256
- return true
257
- }
258
252
  return false
259
253
  }
260
254
 
255
+ /// The queue was empty: `index` becomes the current track AND leads the
256
+ /// shuffle order. One operation because the two must not be separable —
257
+ /// forgetting the second half is the bug this exists to prevent.
258
+ ///
259
+ /// `insert(at:count:)` drops new indices at *random* shuffle positions, so
260
+ /// without the pin the starting track can land last in the order, which reads
261
+ /// as `isLastInPlaybackOrder`: the queue "ends" (and fires
262
+ /// `playerDidEndQueue`) the moment the first track finishes. Pins
263
+ /// unconditionally, like `setQueue` — the order has to be sane whether or not
264
+ /// shuffle is on right now, since `shuffleEnabled`'s didSet only re-pins on
265
+ /// the off→on edge.
266
+ ///
267
+ /// Returns true, so callers can `return adoptInitialCurrent(…)` to report the
268
+ /// current track changed.
269
+ private func adoptInitialCurrent(_ index: Int) -> Bool {
270
+ currentIndex = max(0, min(index, tracks.count - 1))
271
+ shuffleOrder = ShuffleOrder(length: tracks.count, firstIndex: currentIndex)
272
+ return true
273
+ }
274
+
261
275
  /// Remove a track from the queue.
262
276
  /// Returns `true` if the current track changed.
263
277
  @discardableResult
@@ -288,8 +302,18 @@ class QueueManager {
288
302
  let insertion = min(newTracks.count, toIndex)
289
303
  newTracks.insert(track, at: insertion)
290
304
  tracks = newTracks
305
+ // Carry the moved track's shuffle position across the move. `insert(at:count:)`
306
+ // would drop it at a RANDOM position instead — reordering the queue would then
307
+ // silently reorder shuffled playback, and moving the playing track could land it
308
+ // last in the order, ending the queue at the next track boundary with tracks
309
+ // still unplayed (the failure `adoptInitialCurrent` guards against elsewhere).
310
+ // `remove` shifts every later position down by one, so re-inserting at the
311
+ // captured position restores the exact prior order.
312
+ let shufflePosition = shuffleOrder.shufflePosition(of: fromIndex)
291
313
  shuffleOrder.remove(from: fromIndex, to: fromIndex + 1)
292
- shuffleOrder.insert(at: insertion, count: 1)
314
+ shuffleOrder.insert(
315
+ at: insertion, atShufflePosition: shufflePosition ?? shuffleOrder.count,
316
+ )
293
317
  // The pointer follows the playing track; its identity never changes on a
294
318
  // move, so no caller needs to reload (returning true reloads).
295
319
  if currentIndex == fromIndex {
@@ -317,7 +341,9 @@ class QueueManager {
317
341
  // MARK: - Other Mutations
318
342
 
319
343
  /// Replace the track at a specific index.
320
- func replace(_ index: Int, _ track: Track) {
344
+ /// Throws like every other index-taking mutation here, rather than trapping.
345
+ func replace(_ index: Int, _ track: Track) throws {
346
+ try throwIfIndexInvalid(index: index)
321
347
  tracks[index] = track
322
348
  }
323
349
 
@@ -101,6 +101,13 @@ struct ShuffleOrder {
101
101
  return indexInShuffled[index] == 0
102
102
  }
103
103
 
104
+ /// The item's position in the shuffle order, or nil when it has none.
105
+ func shufflePosition(of index: Int) -> Int? {
106
+ guard index >= 0, index < indexInShuffled.count else { return nil }
107
+ let position = indexInShuffled[index]
108
+ return position >= 0 ? position : nil
109
+ }
110
+
104
111
  // MARK: - Mutation
105
112
 
106
113
  /// Inserts items at random positions in the shuffle order
@@ -125,6 +132,22 @@ struct ShuffleOrder {
125
132
  indexInShuffled = Self.buildIndexInShuffled(from: shuffled)
126
133
  }
127
134
 
135
+ /// Inserts one item at `insertionIndex`, placing it at an explicit shuffle
136
+ /// position rather than a random one.
137
+ ///
138
+ /// Used by a queue `move`, which permutes positions but not identities: the
139
+ /// item already had a place in the playback order, and re-randomising it
140
+ /// silently reorders playback. For the *playing* track it can drop it at the
141
+ /// end of the order, which then reads as `isLastInPlaybackOrder` — the queue
142
+ /// reports itself over at the next track end, with tracks still unplayed.
143
+ mutating func insert(at insertionIndex: Int, atShufflePosition position: Int) {
144
+ for i in 0 ..< shuffled.count where shuffled[i] >= insertionIndex {
145
+ shuffled[i] += 1
146
+ }
147
+ shuffled.insert(insertionIndex, at: min(max(0, position), shuffled.count))
148
+ indexInShuffled = Self.buildIndexInShuffled(from: shuffled)
149
+ }
150
+
128
151
  /// Removes items in the specified range from the shuffle order
129
152
  mutating func remove(from indexFrom: Int, to indexToExclusive: Int) {
130
153
  let removeCount = indexToExclusive - indexFrom
@@ -12,4 +12,17 @@ import Foundation
12
12
  @discardableResult func clear() -> Bool
13
13
  func onTrackChanged()
14
14
  func onTrackPlayedToEnd()
15
+
16
+ // Consumer-facing surface — HybridAudioBrowser drives these through
17
+ // TrackPlayer, so they belong here rather than behind a concrete downcast.
18
+
19
+ /// Invoked when the timer state changes (set, cleared, completed).
20
+ var onChanged: ((SleepTimerState) -> Void)? { get set }
21
+ /// The current timer state, or nil when no timer is active.
22
+ func get() -> SleepTimerState?
23
+ /// Sets a time-based timer, optionally fading out over the last
24
+ /// `fadeDuration` seconds.
25
+ func set(seconds: TimeInterval, fadeDuration: TimeInterval?)
26
+ /// Sets the timer to stop playback when the current track ends.
27
+ func setToEndOfTrack()
15
28
  }
@@ -281,24 +281,59 @@ class RemoteCommandController {
281
281
 
282
282
  // MARK: - Handlers
283
283
 
284
- lazy var handlePlayCommand: RemoteCommandHandler = handlePlayCommandDefault
285
- lazy var handlePauseCommand: RemoteCommandHandler = handlePauseCommandDefault
286
- lazy var handleStopCommand: RemoteCommandHandler = handleStopCommandDefault
287
- lazy var handleTogglePlayPauseCommand: RemoteCommandHandler =
288
- handleTogglePlayPauseCommandDefault
289
- lazy var handleSkipForwardCommand: RemoteCommandHandler = handleSkipForwardCommandDefault
290
- lazy var handleSkipBackwardCommand: RemoteCommandHandler = handleSkipBackwardDefault
291
- lazy var handleChangePlaybackPositionCommand: RemoteCommandHandler =
292
- handleChangePlaybackPositionCommandDefault
293
- lazy var handleNextTrackCommand: RemoteCommandHandler = handleNextTrackCommandDefault
294
- lazy var handlePreviousTrackCommand: RemoteCommandHandler =
295
- handlePreviousTrackCommandDefault
296
- lazy var handleChangeRepeatModeCommand: RemoteCommandHandler =
297
- handleChangeRepeatModeCommandDefault
298
- lazy var handleChangeShuffleModeCommand: RemoteCommandHandler =
299
- handleChangeShuffleModeCommandDefault
300
- lazy var handleChangePlaybackRateCommand: RemoteCommandHandler =
301
- handleChangePlaybackRateCommandDefault
284
+ // Each default is wrapped in a `[weak self]` closure rather than assigned as a
285
+ // bare method reference (`= handlePlayCommandDefault`): an unapplied instance
286
+ // method curries to `{ [self] in self.method($0) }`, which strongly captures
287
+ // self. Stored back onto self, that is a permanent cycle — `enable(command:)`
288
+ // forces every lazy init, so the controller would never deallocate, leaking one
289
+ // per player instance across JS reloads and re-setups.
290
+ lazy var handlePlayCommand: RemoteCommandHandler = { [weak self] event in
291
+ self?.handlePlayCommandDefault(event: event) ?? .commandFailed
292
+ }
293
+
294
+ lazy var handlePauseCommand: RemoteCommandHandler = { [weak self] event in
295
+ self?.handlePauseCommandDefault(event: event) ?? .commandFailed
296
+ }
297
+
298
+ lazy var handleStopCommand: RemoteCommandHandler = { [weak self] event in
299
+ self?.handleStopCommandDefault(event: event) ?? .commandFailed
300
+ }
301
+
302
+ lazy var handleTogglePlayPauseCommand: RemoteCommandHandler = { [weak self] event in
303
+ self?.handleTogglePlayPauseCommandDefault(event: event) ?? .commandFailed
304
+ }
305
+
306
+ lazy var handleSkipForwardCommand: RemoteCommandHandler = { [weak self] event in
307
+ self?.handleSkipForwardCommandDefault(event: event) ?? .commandFailed
308
+ }
309
+
310
+ lazy var handleSkipBackwardCommand: RemoteCommandHandler = { [weak self] event in
311
+ self?.handleSkipBackwardDefault(event: event) ?? .commandFailed
312
+ }
313
+
314
+ lazy var handleChangePlaybackPositionCommand: RemoteCommandHandler = { [weak self] event in
315
+ self?.handleChangePlaybackPositionCommandDefault(event: event) ?? .commandFailed
316
+ }
317
+
318
+ lazy var handleNextTrackCommand: RemoteCommandHandler = { [weak self] event in
319
+ self?.handleNextTrackCommandDefault(event: event) ?? .commandFailed
320
+ }
321
+
322
+ lazy var handlePreviousTrackCommand: RemoteCommandHandler = { [weak self] event in
323
+ self?.handlePreviousTrackCommandDefault(event: event) ?? .commandFailed
324
+ }
325
+
326
+ lazy var handleChangeRepeatModeCommand: RemoteCommandHandler = { [weak self] event in
327
+ self?.handleChangeRepeatModeCommandDefault(event: event) ?? .commandFailed
328
+ }
329
+
330
+ lazy var handleChangeShuffleModeCommand: RemoteCommandHandler = { [weak self] event in
331
+ self?.handleChangeShuffleModeCommandDefault(event: event) ?? .commandFailed
332
+ }
333
+
334
+ lazy var handleChangePlaybackRateCommand: RemoteCommandHandler = { [weak self] event in
335
+ self?.handleChangePlaybackRateCommandDefault(event: event) ?? .commandFailed
336
+ }
302
337
 
303
338
  private func handlePlayCommandDefault(event _: MPRemoteCommandEvent)
304
339
  -> MPRemoteCommandHandlerStatus
@@ -0,0 +1,200 @@
1
+ import Testing
2
+
3
+ @testable import AudioBrowserTestable
4
+
5
+ // The dev diagnostic for declarations that can never render (ADR 0011): the
6
+ // recovery for the compile-time invalid-combination errors the block model
7
+ // deliberately gave up. Structural inertness only — a surface that can't draw
8
+ // a property it understands is intended usage, never a finding.
9
+
10
+ @Suite("InertStyleDiagnostic")
11
+ struct InertStyleDiagnosticTests {
12
+ private func findings(
13
+ _ sections: [Section], page: SectionStyle? = nil, path: String = "/home",
14
+ ) -> [String] {
15
+ InertStyleDiagnostic.findings(path: path, pageStyle: page, sections: sections)
16
+ }
17
+
18
+ private func playable(_ src: String = "https://s/a.mp3", style: TrackStyle? = nil) -> Track {
19
+ Track(src: src, title: "T", style: style)
20
+ }
21
+
22
+ private func browsable(_ path: String = "/albums/1", style: TrackStyle? = nil) -> Track {
23
+ Track(path: path, title: "T", style: style)
24
+ }
25
+
26
+ // MARK: - Container properties
27
+
28
+ @Test func gridWrapOnAListSectionIsInert() {
29
+ let found = findings([Section(title: "Recent", style: SectionStyle(gridWrap: false), children: [])])
30
+ #expect(found.count == 1)
31
+ #expect(found[0].contains("section 'Recent'"))
32
+ #expect(found[0].contains("style.gridWrap"))
33
+ #expect(found[0].contains("display 'list'"))
34
+ #expect(found[0].contains("/home"))
35
+ }
36
+
37
+ @Test func gridWrapInAGridIsLive() {
38
+ let style = SectionStyle(gridWrap: false, display: .grid)
39
+ #expect(findings([Section(style: style, children: [])]).isEmpty)
40
+ }
41
+
42
+ @Test func aSectionInheritsThePagesGridDisplay() {
43
+ // `display` resolves by scope override, so the page's grid makes the
44
+ // section's `gridWrap` live without the section restating it.
45
+ let found = findings(
46
+ [Section(style: SectionStyle(gridWrap: false), children: [])],
47
+ page: SectionStyle(display: .grid),
48
+ )
49
+ #expect(found.isEmpty)
50
+ }
51
+
52
+ @Test func gridTileOnAListSectionIsInert() {
53
+ let found = findings([Section(style: SectionStyle(gridTile: .card), children: [])])
54
+ #expect(found.count == 1)
55
+ #expect(found[0].contains("style.gridTile"))
56
+ #expect(found[0].contains("section #1"))
57
+ }
58
+
59
+ // MARK: - Item properties
60
+
61
+ @Test func cardPropertiesNeedTheCardTreatment() {
62
+ let plainGrid = SectionStyle(display: .grid, cardTint: "#1e3a8a", cardImage: .background)
63
+ let found = findings([Section(title: "Featured", style: plainGrid, children: [])])
64
+ #expect(found.count == 2)
65
+ #expect(found.contains { $0.contains("style.cardTint") })
66
+ #expect(found.contains { $0.contains("style.cardImage") })
67
+ #expect(found.allSatisfy { $0.contains("display 'grid', gridTile 'plain'") })
68
+ }
69
+
70
+ @Test func cardPropertiesInACardGridAreLive() {
71
+ let cards = SectionStyle(gridTile: .card, display: .grid, cardTint: "#1e3a8a", cardImage: .background)
72
+ #expect(findings([Section(style: cards, children: [])]).isEmpty)
73
+ }
74
+
75
+ @Test func imageShapeNeedsAShapedTile() {
76
+ let list = SectionStyle(imageShape: .circular)
77
+ let cards = SectionStyle(gridTile: .card, display: .grid, imageShape: .circular)
78
+ let condensed = SectionStyle(gridTile: .condensed, display: .grid, imageShape: .circular)
79
+ #expect(findings([Section(style: list, children: [])]).count == 1)
80
+ #expect(findings([Section(style: cards, children: [])]).count == 1)
81
+ // Plain and condensed tiles both take a shape.
82
+ #expect(findings([Section(style: condensed, children: [])]).isEmpty)
83
+ }
84
+
85
+ @Test func accessorySymbolIsInertOnlyOnCards() {
86
+ let rows = SectionStyle(accessorySymbol: "lock.fill")
87
+ let cards = SectionStyle(gridTile: .card, display: .grid, accessorySymbol: "lock.fill")
88
+ // List rows draw accessories; card elements have no slot for one.
89
+ #expect(findings([Section(style: rows, children: [])]).isEmpty)
90
+ let found = findings([Section(style: cards, children: [])])
91
+ #expect(found.count == 1)
92
+ #expect(found[0].contains("no accessory slot"))
93
+ }
94
+
95
+ @Test func artworkRenderingIsNeverInert() {
96
+ let stencilled = SectionStyle(gridTile: .card, display: .grid, artworkRendering: .stencil)
97
+ #expect(findings([Section(style: stencilled, children: [])]).isEmpty)
98
+ }
99
+
100
+ // MARK: - Track-level declarations
101
+
102
+ @Test func trackDeclarationsAreReportedPerSection() {
103
+ let section = Section(
104
+ title: "Results", style: SectionStyle(display: .grid),
105
+ children: [
106
+ playable(style: TrackStyle(cardTint: "#111")),
107
+ playable("https://s/b.mp3", style: TrackStyle(cardTint: "#222")),
108
+ playable("https://s/c.mp3"),
109
+ ],
110
+ )
111
+ let found = findings([section])
112
+ #expect(found.count == 1)
113
+ #expect(found[0].contains("2 tracks in section 'Results'"))
114
+ #expect(found[0].contains("style.cardTint"))
115
+ }
116
+
117
+ @Test func aTracksOwnShapeIsLiveInAPlainGrid() {
118
+ let section = Section(
119
+ style: SectionStyle(display: .grid),
120
+ children: [playable(style: TrackStyle(imageShape: .circular))],
121
+ )
122
+ #expect(findings([section]).isEmpty)
123
+ }
124
+
125
+ // MARK: - The page block
126
+
127
+ @Test func aPageDeclarationLiveInOneSectionIsNotReported() {
128
+ // Card tint reaches both sections; one renders cards. Live is live.
129
+ let found = findings(
130
+ [
131
+ Section(style: SectionStyle(gridTile: .card, display: .grid), children: []),
132
+ Section(style: SectionStyle(display: .grid), children: []),
133
+ ],
134
+ page: SectionStyle(cardTint: "#1e3a8a"),
135
+ )
136
+ #expect(found.isEmpty)
137
+ }
138
+
139
+ @Test func aPageDeclarationNoSectionRendersIsReportedOnce() {
140
+ let found = findings(
141
+ [
142
+ Section(title: "A", style: SectionStyle(display: .grid), children: []),
143
+ Section(title: "B", children: []),
144
+ ],
145
+ page: SectionStyle(cardTint: "#1e3a8a"),
146
+ )
147
+ #expect(found.count == 1)
148
+ #expect(found[0].contains("the page block"))
149
+ #expect(found[0].contains("no section that inherits it"))
150
+ }
151
+
152
+ @Test func aSectionOverrideShadowsThePageValue() {
153
+ // Every section declares its own tint, so the page's is never resolved —
154
+ // shadowed, not inert; the diagnostic reports what can't render, not what
155
+ // isn't reached.
156
+ let found = findings(
157
+ [Section(style: SectionStyle(gridTile: .card, display: .grid, cardTint: "#000"), children: [])],
158
+ page: SectionStyle(cardTint: "#1e3a8a"),
159
+ )
160
+ #expect(found.isEmpty)
161
+ }
162
+
163
+ @Test func aPageWideGridMakesSectionDeclarationsLive() {
164
+ let found = findings(
165
+ [Section(style: SectionStyle(cardTint: "#111"), children: [])],
166
+ page: SectionStyle(gridTile: .card, display: .grid),
167
+ )
168
+ #expect(found.isEmpty)
169
+ }
170
+
171
+ // MARK: - The positional `display`
172
+
173
+ @Test func displayOnAPlayableTrackIsInert() {
174
+ let section = Section(
175
+ title: "Tracks",
176
+ children: [
177
+ playable(style: TrackStyle(display: .grid)),
178
+ browsable(style: TrackStyle(display: .grid)),
179
+ ],
180
+ )
181
+ let found = findings([section])
182
+ #expect(found.count == 1)
183
+ #expect(found[0].contains("1 track in section 'Tracks'"))
184
+ #expect(found[0].contains("style.display"))
185
+ #expect(found[0].contains("opens none"))
186
+ }
187
+
188
+ @Test func displayOnASectionOrPageIsNeverInert() {
189
+ let found = findings(
190
+ [Section(style: SectionStyle(display: .list), children: [browsable()])],
191
+ page: SectionStyle(display: .grid),
192
+ )
193
+ #expect(found.isEmpty)
194
+ }
195
+
196
+ @Test func anUnstyledPageIsSilent() {
197
+ #expect(findings([Section(children: [playable(), browsable()])]).isEmpty)
198
+ #expect(findings([]).isEmpty)
199
+ }
200
+ }
@@ -117,11 +117,70 @@ struct JsonStyleTests {
117
117
  #expect(track.disabled == true)
118
118
  }
119
119
 
120
+ @Test func decodesImageShapeAndAccessorySymbol() throws {
121
+ let json = Data(
122
+ """
123
+ {"title":"X","style":{"imageShape":"circular","accessorySymbol":"lock.fill"}}
124
+ """.utf8)
125
+ let track = try JSONDecoder().decode(JsonTrack.self, from: json).toNitro()
126
+ #expect(track.style?.imageShape == .circular)
127
+ #expect(track.style?.accessorySymbol == "lock.fill")
128
+ }
129
+
130
+ @Test func accessorySymbolNoneIsAValueNotEmptiness() throws {
131
+ // The inheritance-escape sentinel must survive the wire — collapsing it
132
+ // to "no declaration" would let the section value flow back in.
133
+ let json = Data(
134
+ """
135
+ {"title":"X","style":{"accessorySymbol":"none"}}
136
+ """.utf8)
137
+ let track = try JSONDecoder().decode(JsonTrack.self, from: json).toNitro()
138
+ #expect(track.style?.accessorySymbol == "none")
139
+ }
140
+
141
+ @Test func unknownImageShapeDecodesAsNoDeclaration() throws {
142
+ let json = Data(
143
+ """
144
+ {"title":"X","style":{"imageShape":"hexagonal"}}
145
+ """.utf8)
146
+ let track = try JSONDecoder().decode(JsonTrack.self, from: json).toNitro()
147
+ #expect(track.style == nil)
148
+ }
149
+
150
+ @Test func decodesTheCardFamily() throws {
151
+ let json = Data(
152
+ """
153
+ {"path":"/home","title":"Home","sections":[
154
+ {"style":{"display":"grid","gridTile":"card","cardTint":"#1e3a8a","cardImage":"background"},
155
+ "children":[{"title":"C","src":"s"}]}
156
+ ]}
157
+ """.utf8)
158
+ let resolved = try JSONDecoder().decode(JsonResolvedTrack.self, from: json).toNitro()
159
+ let style = resolved.sections?.first?.style
160
+ #expect(style?.gridTile == .card)
161
+ #expect(style?.cardTint == "#1e3a8a")
162
+ #expect(style?.cardImage == .background)
163
+ }
164
+
165
+ @Test func unknownGridTileDecodesAsNoDeclaration() throws {
166
+ let json = Data(
167
+ """
168
+ {"path":"/home","title":"Home","sections":[
169
+ {"style":{"gridTile":"hexagon"},"children":[{"title":"C","src":"s"}]}
170
+ ]}
171
+ """.utf8)
172
+ let resolved = try JSONDecoder().decode(JsonResolvedTrack.self, from: json).toNitro()
173
+ #expect(resolved.sections?.first?.style == nil)
174
+ }
175
+
120
176
  @Test func styleAndDisabledSurviveThePersistenceRoundTrip() throws {
121
177
  // Restored queues keep their visible fidelity across process death.
122
178
  let original = JsonTrack(
123
179
  title: "X", src: "s",
124
- style: JsonStyle(display: "grid", artworkRendering: "stencil"),
180
+ style: JsonStyle(
181
+ display: "grid", artworkRendering: "stencil",
182
+ imageShape: "circular", accessorySymbol: "lock.fill",
183
+ ),
125
184
  disabled: true,
126
185
  )
127
186
  let encoded = try JSONEncoder().encode(original)
@@ -129,4 +188,20 @@ struct JsonStyleTests {
129
188
  #expect(decoded.style == original.style)
130
189
  #expect(decoded.disabled == true)
131
190
  }
191
+
192
+ @Test func liveBlockSnapshotsAllItemProperties() throws {
193
+ // The persistence snapshot (JsonStyle from a live TrackStyle) must not
194
+ // drop the newer item properties.
195
+ let snapshot = JsonStyle(
196
+ TrackStyle(
197
+ display: nil, artworkRendering: .stencil,
198
+ imageShape: .circular, accessorySymbol: "none",
199
+ cardTint: "#1e3a8a", cardImage: .background,
200
+ ))
201
+ #expect(snapshot?.artworkRendering == "stencil")
202
+ #expect(snapshot?.imageShape == "circular")
203
+ #expect(snapshot?.accessorySymbol == "none")
204
+ #expect(snapshot?.cardTint == "#1e3a8a")
205
+ #expect(snapshot?.cardImage == "background")
206
+ }
132
207
  }