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

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 (156) 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 +5 -1
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +8 -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 -9
  7. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +11 -0
  8. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +11 -1
  9. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
  10. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +17 -2
  11. package/android/src/main/java/com/audiobrowser/player/Player.kt +3 -0
  12. package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
  13. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +73 -0
  14. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -1
  15. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +49 -3
  16. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +3 -2
  17. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
  18. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +56 -0
  19. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +4 -4
  20. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +77 -10
  21. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +2 -2
  22. package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
  23. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +8 -8
  24. package/ios/Browser/BrowserManager.swift +106 -45
  25. package/ios/Browser/InertStyleDiagnostic.swift +279 -0
  26. package/ios/Browser/JsonModels.swift +81 -10
  27. package/ios/Browser/SectionPresentation.swift +30 -0
  28. package/ios/Browser/StyleResolver.swift +11 -0
  29. package/ios/Browser/TabBarEntries.swift +7 -1
  30. package/ios/CarPlay/CarPlayController.swift +139 -35
  31. package/ios/CarPlay/CarPlayListItemFactory.swift +99 -27
  32. package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
  33. package/ios/Http/HttpClient.swift +8 -2
  34. package/ios/HybridAudioBrowser.swift +54 -11
  35. package/ios/Model/NitroTypeStubs.swift +60 -0
  36. package/ios/Player/MediaIntentCriteria.swift +13 -5
  37. package/ios/Player/MediaLoader.swift +33 -9
  38. package/ios/Player/NowPlayingUpdater.swift +53 -5
  39. package/ios/Player/PlaybackCoordinator.swift +11 -4
  40. package/ios/Player/PlaybackTimer.swift +8 -2
  41. package/ios/Player/QueueManager.swift +36 -10
  42. package/ios/Player/ShuffleOrder.swift +23 -0
  43. package/ios/Player/SleepTimerHandling.swift +13 -0
  44. package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
  45. package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
  46. package/ios/Tests/JsonModelsTests.swift +76 -1
  47. package/ios/Tests/QueueManagerTests.swift +72 -6
  48. package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
  49. package/ios/Tests/SectionPresentationTests.swift +29 -0
  50. package/ios/Tests/StyleResolverTests.swift +60 -0
  51. package/ios/Tests/Support/TestTypes.swift +7 -0
  52. package/ios/TrackPlayer.swift +57 -34
  53. package/ios/Util/NetworkMonitor.swift +43 -26
  54. package/ios/Util/SFSymbolRenderer.swift +24 -2
  55. package/ios/Util/SVGProcessor.swift +9 -2
  56. package/lib/commonjs/features/playback/progress.js +2 -1
  57. package/lib/commonjs/features/playback/progress.js.map +1 -1
  58. package/lib/commonjs/features/playback/rate.js +8 -1
  59. package/lib/commonjs/features/playback/rate.js.map +1 -1
  60. package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
  61. package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
  62. package/lib/module/features/playback/progress.js +2 -1
  63. package/lib/module/features/playback/progress.js.map +1 -1
  64. package/lib/module/features/playback/rate.js +8 -1
  65. package/lib/module/features/playback/rate.js.map +1 -1
  66. package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
  67. package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
  68. package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
  69. package/lib/typescript/src/features/playback/rate.d.ts +8 -1
  70. package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
  71. package/lib/typescript/src/types/browser-nodes.d.ts +104 -2
  72. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  73. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +4 -0
  74. package/nitrogen/generated/android/c++/JBrowseResult.hpp +6 -0
  75. package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
  76. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +4 -0
  77. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +4 -0
  78. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +4 -0
  79. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +4 -0
  80. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +4 -0
  81. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +4 -0
  82. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +4 -0
  83. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +4 -0
  84. 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
  85. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +4 -0
  86. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +4 -0
  87. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +6 -0
  88. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +4 -0
  89. package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
  90. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +12 -0
  91. package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
  92. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +4 -0
  93. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +6 -0
  94. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +6 -0
  95. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +4 -0
  96. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +4 -0
  97. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +6 -0
  98. package/nitrogen/generated/android/c++/JSection.hpp +6 -0
  99. package/nitrogen/generated/android/c++/JSectionStyle.hpp +30 -3
  100. package/nitrogen/generated/android/c++/JTrack.hpp +4 -0
  101. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +4 -0
  102. package/nitrogen/generated/android/c++/JTrackStyle.hpp +24 -3
  103. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +6 -0
  104. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
  105. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
  106. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
  107. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +18 -3
  108. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +15 -3
  109. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +54 -0
  110. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +9 -0
  111. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +9 -0
  112. package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
  113. package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
  114. package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
  115. package/nitrogen/generated/ios/swift/SectionStyle.swift +70 -1
  116. package/nitrogen/generated/ios/swift/TrackStyle.swift +59 -1
  117. package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
  118. package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
  119. package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
  120. package/nitrogen/generated/shared/c++/SectionStyle.hpp +32 -2
  121. package/nitrogen/generated/shared/c++/TrackStyle.hpp +25 -2
  122. package/package.json +7 -2
  123. package/src/features/playback/progress.ts +2 -1
  124. package/src/features/playback/rate.ts +8 -1
  125. package/src/style-resolution.test.ts +72 -1
  126. package/src/types/browser-nodes.ts +112 -2
  127. package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
  128. package/src/web/TrackPlayer/QueueManager.ts +14 -0
  129. package/website/guide/android-auto.md +114 -0
  130. package/website/guide/android-certificates.md +409 -0
  131. package/website/guide/artwork.md +255 -0
  132. package/website/guide/audio-output.md +135 -0
  133. package/website/guide/automotive.md +86 -0
  134. package/website/guide/basic-usage.md +151 -0
  135. package/website/guide/battery.md +115 -0
  136. package/website/guide/browser.md +581 -0
  137. package/website/guide/carplay.md +620 -0
  138. package/website/guide/configuration.md +173 -0
  139. package/website/guide/equalizer.md +231 -0
  140. package/website/guide/errors.md +310 -0
  141. package/website/guide/favorites.md +154 -0
  142. package/website/guide/gate.md +143 -0
  143. package/website/guide/getting-started.md +46 -0
  144. package/website/guide/hooks.md +238 -0
  145. package/website/guide/live-streams.md +125 -0
  146. package/website/guide/metadata.md +239 -0
  147. package/website/guide/migrating-from-track-player.md +244 -0
  148. package/website/guide/native-callback-fetch.md +127 -0
  149. package/website/guide/network.md +86 -0
  150. package/website/guide/now-playing.md +175 -0
  151. package/website/guide/playback.md +328 -0
  152. package/website/guide/queue.md +300 -0
  153. package/website/guide/remote-controls.md +256 -0
  154. package/website/guide/search.md +255 -0
  155. package/website/guide/sleep-timer.md +268 -0
  156. package/website/guide/track.md +246 -0
@@ -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
  }