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
@@ -120,6 +120,41 @@ struct NextTests {
120
120
  #expect(q.shuffleOrder.shuffled == order)
121
121
  }
122
122
 
123
+ /// A queue reorder permutes positions, not identities, so it must leave the
124
+ /// shuffled playback order alone. Re-inserting at a random shuffle position
125
+ /// would silently reshuffle under the listener.
126
+ @Test func shuffle_moveKeepsThePlaybackOrder() {
127
+ for target in 0 ..< 5 {
128
+ let q = QueueManager()
129
+ q.setQueue(tracks("a", "b", "c", "d", "e"))
130
+ q.shuffleEnabled = true
131
+ let before = q.shuffleOrder.shuffled.map { q.tracks[$0].id }
132
+
133
+ _ = try? q.move(fromIndex: 0, toIndex: target)
134
+
135
+ #expect(q.shuffleOrder.shuffled.map { q.tracks[$0].id } == before)
136
+ }
137
+ }
138
+
139
+ /// The same hazard as `shuffle_startTrackLeadsTheOrder`, reached by moving the
140
+ /// *playing* track: a random re-insert can drop it last in the shuffle order,
141
+ /// which reads as `isLastInPlaybackOrder` and ends the queue at the next track
142
+ /// boundary with tracks still unplayed.
143
+ @Test func shuffle_movingCurrentTrackDoesNotEndTheQueue() {
144
+ for target in 1 ..< 6 {
145
+ let q = QueueManager()
146
+ q.setQueue(tracks("a", "b", "c", "d", "e", "f"), initialIndex: 0)
147
+ q.shuffleEnabled = true // pins "a" first in the order
148
+ #expect(!q.isLastInPlaybackOrder)
149
+
150
+ _ = try? q.move(fromIndex: 0, toIndex: target)
151
+
152
+ #expect(q.currentTrack?.id == "a")
153
+ #expect(!q.isLastInPlaybackOrder)
154
+ #expect(q.nextTracks.count == 5)
155
+ }
156
+ }
157
+
123
158
  @Test func shuffle_visitsAllTracks() {
124
159
  let q = QueueManager()
125
160
  q.setQueue(tracks("a", "b", "c", "d"))
@@ -382,6 +417,25 @@ struct AddTests {
382
417
  #expect(q.tracks[2].id == "c")
383
418
  #expect(q.tracks[3].id == "d")
384
419
  }
420
+
421
+ /// Filling an empty queue must pin the start track to the head of the shuffle
422
+ /// order, exactly as `setQueue` does — `insert(at:count:)` places new indices
423
+ /// at random positions, so without the pin the start track can land last and
424
+ /// the queue reports end-of-queue as soon as it finishes. `shuffleEnabled`'s
425
+ /// didSet only re-pins on the off→on edge, so shuffle-already-on gets nothing
426
+ /// from it.
427
+ @Test func toEmptyQueue_withShuffleAlreadyOn_startTrackLeadsTheOrder() {
428
+ for start in 0 ..< 6 {
429
+ let q = QueueManager()
430
+ q.shuffleEnabled = true
431
+ q.add(tracks("a", "b", "c", "d", "e", "f"), initialIndex: start)
432
+
433
+ #expect(q.shuffleOrder.isFirst(start))
434
+ #expect(!q.isLastInPlaybackOrder)
435
+ #expect(q.nextTracks.count == 5)
436
+ #expect(q.previousTracks.isEmpty)
437
+ }
438
+ }
385
439
  }
386
440
 
387
441
  // MARK: - Mutations: addAt()
@@ -414,6 +468,18 @@ struct AddAtTests {
414
468
  #expect(q.currentIndex == 0)
415
469
  }
416
470
 
471
+ /// Same pinning requirement as `add()` into an empty queue — see its twin.
472
+ @Test func toEmptyQueue_withShuffleAlreadyOn_startTrackLeadsTheOrder() throws {
473
+ let q = QueueManager()
474
+ q.shuffleEnabled = true
475
+ try q.addAt(tracks("a", "b", "c", "d", "e", "f"), at: 0)
476
+
477
+ #expect(q.shuffleOrder.isFirst(0))
478
+ #expect(!q.isLastInPlaybackOrder)
479
+ #expect(q.nextTracks.count == 5)
480
+ #expect(q.previousTracks.isEmpty)
481
+ }
482
+
417
483
  @Test func invalidIndex_throws() {
418
484
  let q = QueueManager()
419
485
  q.setQueue(tracks("a"))
@@ -691,10 +757,10 @@ struct ClearTests {
691
757
  @Suite("replace & removeUpcomingTracks")
692
758
  @MainActor
693
759
  struct OtherMutationTests {
694
- @Test func replace_updatesTrackAtIndex() {
760
+ @Test func replace_updatesTrackAtIndex() throws {
695
761
  let q = QueueManager()
696
762
  q.setQueue(tracks("a", "b", "c"))
697
- q.replace(1, Track(id: "B"))
763
+ try q.replace(1, Track(id: "B"))
698
764
  #expect(q.tracks[1].id == "B")
699
765
  }
700
766
 
@@ -738,10 +804,10 @@ struct OtherMutationTests {
738
804
  #expect(q.tracks[0].id == "a")
739
805
  }
740
806
 
741
- @Test func replace_currentTrack_reflectsInCurrentTrack() {
807
+ @Test func replace_currentTrack_reflectsInCurrentTrack() throws {
742
808
  let q = QueueManager()
743
809
  q.setQueue(tracks("a", "b", "c"), initialIndex: 1)
744
- q.replace(1, Track(id: "B"))
810
+ try q.replace(1, Track(id: "B"))
745
811
  #expect(q.currentTrack?.id == "B")
746
812
  #expect(q.currentIndex == 1)
747
813
  }
@@ -926,12 +992,12 @@ struct DelegateTests {
926
992
  #expect(spy.calls.count == 1)
927
993
  }
928
994
 
929
- @Test func queueDidChangeTracks_firesOnReplace() {
995
+ @Test func queueDidChangeTracks_firesOnReplace() throws {
930
996
  let q = QueueManager()
931
997
  q.setQueue(tracks("a", "b"))
932
998
  let spy = DelegateSpy()
933
999
  q.delegate = spy
934
- q.replace(0, Track(id: "A"))
1000
+ try q.replace(0, Track(id: "A"))
935
1001
  #expect(spy.calls.count == 1)
936
1002
  }
937
1003
 
@@ -136,6 +136,22 @@ struct RemoteCommandControllerTests {
136
136
  #expect(center.previousTrackCommand.isEnabled)
137
137
  }
138
138
 
139
+ /// The handler properties must not capture the controller strongly. Assigning a
140
+ /// bare method reference (`= handlePlayCommandDefault`) curries to
141
+ /// `{ [self] in self.method($0) }`, and storing that back on self is a permanent
142
+ /// cycle — one leaked controller per player instance, across every JS reload.
143
+ @Test func doesNotRetainItselfThroughHandlers() {
144
+ weak var weakController: RemoteCommandController?
145
+ autoreleasepool {
146
+ let (controller, _) = makeController()
147
+ // Forces every lazy handler to initialize, which is what closes the cycle.
148
+ controller.enable(commands: RemoteCommand.all())
149
+ controller.disableAll()
150
+ weakController = controller
151
+ }
152
+ #expect(weakController == nil)
153
+ }
154
+
139
155
  @Test func shuffleAndRepeatStateSurviveCommandCenterSwitch() {
140
156
  let (controller, _) = makeController()
141
157
  let center = MPRemoteCommandCenter.shared()
@@ -32,7 +32,7 @@ private func makeFullResolvedTrack() -> ResolvedTrack {
32
32
  ResolvedTrack(
33
33
  path: "/original",
34
34
  style: SectionStyle(gridWrap: false, display: .grid, artworkRendering: .stencil),
35
- sections: [Section(title: "Group A", subtitle: nil, style: nil, path: nil, children: [])],
35
+ sections: [Section(title: "Group A", style: nil, path: nil, children: [])],
36
36
  children: [Track(id: "t1", path: "/t1")],
37
37
  carPlaySiriListButton: .top,
38
38
  id: "original-id",
@@ -162,7 +162,7 @@ private func makeFullResolvedTrack() -> ResolvedTrack {
162
162
  @Test func copyingOverridesSections() {
163
163
  let original = makeResolvedTrack()
164
164
  let sections = [
165
- Section(title: "A", subtitle: nil, style: nil, path: nil, children: [Track(id: "c1", src: "c1")]),
165
+ Section(title: "A", style: nil, path: nil, children: [Track(id: "c1", src: "c1")]),
166
166
  ]
167
167
  let copy = original.copying(sections: sections)
168
168
  #expect(copy.sections == sections)
@@ -58,4 +58,33 @@ struct SectionPresentationTests {
58
58
  SectionPresentation(for: style(display: nil, gridWrap: false), supportsWrappingGrid: true)
59
59
  == .list)
60
60
  }
61
+
62
+ @Test func tileFamilyFollowsGridTileWithThePlainSplit() {
63
+ // Card/condensed follow the declaration in either wrap mode; the plain
64
+ // default splits on the wrap: single-line keeps row elements (the
65
+ // subtitle slot), wrapping renders the image grid.
66
+ #expect(
67
+ SectionPresentation.tileFamily(for: SectionStyle(gridTile: .card), singleLine: true)
68
+ == .cardElements)
69
+ #expect(
70
+ SectionPresentation.tileFamily(for: SectionStyle(gridTile: .condensed), singleLine: false)
71
+ == .condensedElements)
72
+ #expect(SectionPresentation.tileFamily(for: nil, singleLine: true) == .rowElements)
73
+ #expect(SectionPresentation.tileFamily(for: nil, singleLine: false) == .imageGridElements)
74
+ #expect(
75
+ SectionPresentation.tileFamily(for: SectionStyle(gridTile: .plain), singleLine: true)
76
+ == .rowElements)
77
+ }
78
+
79
+ @Test func effectiveAccessorySymbol_noneAndAbsenceDrawNothing() {
80
+ // A declared symbol draws; 'none' (the inheritance escape) and absence
81
+ // both fall back to the derived accessory.
82
+ #expect(
83
+ SectionPresentation.effectiveAccessorySymbol(TrackStyle(accessorySymbol: "lock.fill"))
84
+ == "lock.fill")
85
+ #expect(
86
+ SectionPresentation.effectiveAccessorySymbol(TrackStyle(accessorySymbol: "none")) == nil)
87
+ #expect(SectionPresentation.effectiveAccessorySymbol(TrackStyle()) == nil)
88
+ #expect(SectionPresentation.effectiveAccessorySymbol(nil) == nil)
89
+ }
61
90
  }
@@ -33,6 +33,21 @@ struct SectionStyleResolutionTests {
33
33
  #expect(resolved.display == nil)
34
34
  #expect(resolved.artworkRendering == nil)
35
35
  #expect(resolved.gridWrap == nil)
36
+ #expect(resolved.gridTile == nil)
37
+ }
38
+
39
+ @Test func gridTileResolvesByScopeOverride() {
40
+ // A page-wide card treatment; one section opts back to plain tiles.
41
+ let inherited = StyleResolver.sectionStyle(
42
+ section: nil,
43
+ page: SectionStyle(gridTile: .card),
44
+ )
45
+ #expect(inherited.gridTile == .card)
46
+ let overridden = StyleResolver.sectionStyle(
47
+ section: SectionStyle(gridTile: .plain),
48
+ page: SectionStyle(gridTile: .card),
49
+ )
50
+ #expect(overridden.gridTile == .plain)
36
51
  }
37
52
  }
38
53
 
@@ -64,4 +79,49 @@ struct TrackStyleResolutionTests {
64
79
  )
65
80
  #expect(resolved.display == nil)
66
81
  }
82
+
83
+ @Test func imageShapeInheritsAndOverridesPerItem() {
84
+ // Section-wide circular (an artists shelf); one album overrides.
85
+ let inherited = StyleResolver.trackStyle(
86
+ track: nil,
87
+ section: SectionStyle(imageShape: .circular),
88
+ )
89
+ #expect(inherited.imageShape == .circular)
90
+ let overridden = StyleResolver.trackStyle(
91
+ track: TrackStyle(imageShape: .roundedRectangle),
92
+ section: SectionStyle(imageShape: .circular),
93
+ )
94
+ #expect(overridden.imageShape == .roundedRectangle)
95
+ }
96
+
97
+ @Test func accessorySymbolInheritsAndNoneResolvesAsAValue() {
98
+ let inherited = StyleResolver.trackStyle(
99
+ track: nil,
100
+ section: SectionStyle(accessorySymbol: "lock.fill"),
101
+ )
102
+ #expect(inherited.accessorySymbol == "lock.fill")
103
+ // 'none' is the inheritance escape — it must survive resolution intact
104
+ // (the renderer, not the resolver, maps it to "no accessory").
105
+ let escaped = StyleResolver.trackStyle(
106
+ track: TrackStyle(accessorySymbol: "none"),
107
+ section: SectionStyle(accessorySymbol: "lock.fill"),
108
+ )
109
+ #expect(escaped.accessorySymbol == "none")
110
+ }
111
+
112
+ @Test func cardPropertiesInheritAndOverridePerItem() {
113
+ // Section-wide tint/mode; one card overrides its image mode.
114
+ let inherited = StyleResolver.trackStyle(
115
+ track: nil,
116
+ section: SectionStyle(accessorySymbol: nil, cardTint: "#1e3a8a", cardImage: .background),
117
+ )
118
+ #expect(inherited.cardTint == "#1e3a8a")
119
+ #expect(inherited.cardImage == .background)
120
+ let overridden = StyleResolver.trackStyle(
121
+ track: TrackStyle(cardImage: .normal),
122
+ section: SectionStyle(cardTint: "#1e3a8a", cardImage: .background),
123
+ )
124
+ #expect(overridden.cardImage == .normal)
125
+ #expect(overridden.cardTint == "#1e3a8a")
126
+ }
67
127
  }
@@ -185,6 +185,7 @@ final class MockSleepTimerHandling: SleepTimerHandling {
185
185
  var onComplete: (() -> Void)?
186
186
  var onFadeStart: ((_ duration: TimeInterval) -> Void)?
187
187
  var onFadeCancel: (() -> Void)?
188
+ var onChanged: ((SleepTimerState) -> Void)?
188
189
  var trackChangedCallCount = 0
189
190
  var trackPlayedToEndCallCount = 0
190
191
  var clearCallCount = 0
@@ -196,6 +197,12 @@ final class MockSleepTimerHandling: SleepTimerHandling {
196
197
 
197
198
  func onTrackChanged() { trackChangedCallCount += 1 }
198
199
  func onTrackPlayedToEnd() { trackPlayedToEndCallCount += 1 }
200
+
201
+ // Consumer-facing surface — unused by the coordinator tests, present only to
202
+ // satisfy the protocol. Add recorders alongside the tests that need them.
203
+ func get() -> SleepTimerState? { nil }
204
+ func set(seconds _: TimeInterval, fadeDuration _: TimeInterval?) {}
205
+ func setToEndOfTrack() {}
199
206
  }
200
207
 
201
208
  // MARK: - Mock RetryHandling
@@ -136,7 +136,7 @@ class TrackPlayer {
136
136
  var previousTracks: [Track] { coordinator.previousTracks }
137
137
  var isLastInPlaybackOrder: Bool { coordinator.isLastInPlaybackOrder }
138
138
  var playbackActive: Bool { coordinator.playbackActive }
139
- var sleepTimerManager: SleepTimerManager { coordinator.sleepTimerManager as! SleepTimerManager }
139
+ var sleepTimerManager: any SleepTimerHandling { coordinator.sleepTimerManager }
140
140
  var queue: QueueManager { coordinator.queue }
141
141
  var loadSeekCoordinator: LoadSeekCoordinator { coordinator.loadSeekCoordinator }
142
142
  var playingStateManager: PlayingStateManager { coordinator.playingStateManager }
@@ -195,12 +195,14 @@ class TrackPlayer {
195
195
  return seconds.isNaN ? 0 : seconds
196
196
  }
197
197
 
198
+ /// The current item's duration, or 0 when it has none (a live stream).
199
+ ///
200
+ /// Don't reintroduce `item.asset.duration`: it is deprecated as of the iOS 16
201
+ /// minimum and blocks the main actor fetching a remote asset's duration. Read
202
+ /// on every progress tick.
198
203
  var duration: Double {
199
204
  guard let item = avPlayer.currentItem else { return 0.0 }
200
205
 
201
- if !item.asset.duration.seconds.isNaN {
202
- return item.asset.duration.seconds
203
- }
204
206
  if !item.duration.seconds.isNaN {
205
207
  return item.duration.seconds
206
208
  }
@@ -252,8 +254,22 @@ class TrackPlayer {
252
254
  var rate: Float {
253
255
  get { coordinator.rate }
254
256
  set {
257
+ // A non-positive rate leaves the player unable to play at all — and would
258
+ // poison the next play() through `defaultRate` below. Rejected here, at
259
+ // the one setter every source funnels through (JS, remote command, CarPlay
260
+ // rate cycling, a restored snapshot), rather than at each of them.
261
+ guard newValue > 0 else { return }
255
262
  coordinator.rate = newValue
256
- avPlayer.rate = newValue
263
+ // Assigning a nonzero `AVPlayer.rate` IS `play()` — so setting the speed
264
+ // while paused would start audible playback behind the user's back, with
265
+ // `playWhenReady` still false (the lock screen would read "paused" over
266
+ // sound). On Android and web the speed control is independent of the play
267
+ // intent; this keeps iOS the same.
268
+ avPlayer.defaultRate = newValue
269
+ if playWhenReady { avPlayer.rate = newValue }
270
+ // Same reason as the repeat/shuffle edges below — a speed changed while
271
+ // paused would otherwise not reach the snapshot the cold resume restores.
272
+ persistPlaybackState()
257
273
  }
258
274
  }
259
275
 
@@ -312,17 +328,6 @@ class TrackPlayer {
312
328
  coordinator.load(track, playWhenReady: playWhenReady)
313
329
  }
314
330
 
315
- func togglePlaying() {
316
- switch avPlayer.timeControlStatus {
317
- case .playing, .waitingToPlayAtSpecifiedRate:
318
- pause()
319
- case .paused:
320
- play()
321
- @unknown default:
322
- fatalError("Unknown AVPlayer.timeControlStatus")
323
- }
324
- }
325
-
326
331
  func play() { coordinator.play() }
327
332
  func pause() { coordinator.pause() }
328
333
  func togglePlayback() { coordinator.togglePlayback() }
@@ -456,13 +461,17 @@ class TrackPlayer {
456
461
  )
457
462
  }
458
463
 
464
+ /// Empties the queue and the published now-playing info. Unlinking the
465
+ /// session belongs in `destroy()`: `avPlayer` survives a clear, and only
466
+ /// `setupAVPlayer()` re-links.
459
467
  func clear() {
460
468
  coordinator.clear()
461
- nowPlayingInfoController.unlinkPlayer()
462
469
  }
463
470
 
464
471
  func destroy() {
465
472
  clear()
473
+ // The player is going away for good — now the session must go with it.
474
+ nowPlayingInfoController.unlinkPlayer()
466
475
  // An AVPlayer retains its time observers: one left registered is still attached when the
467
476
  // player is released, and outlives this object.
468
477
  playerTimeObserver.unregisterForBoundaryTimeEvents()
@@ -550,10 +559,12 @@ class TrackPlayer {
550
559
 
551
560
  nowPlayingInfoController.linkPlayer(avPlayer)
552
561
 
562
+ // Stage the rate unconditionally, so a freshly recreated player carries it
563
+ // before anything plays — `rate`'s setter holds the same invariant, and on
564
+ // the playWhenReady branch below `defaultRate` was previously left at 1.0.
565
+ avPlayer.defaultRate = rate
553
566
  if playWhenReady {
554
567
  startPlayback()
555
- } else {
556
- avPlayer.defaultRate = rate
557
568
  }
558
569
  }
559
570
 
@@ -755,13 +766,28 @@ extension TrackPlayer {
755
766
  PersistedPlaybackState(
756
767
  track: JsonTrack(from: track),
757
768
  positionMs: positionMs,
758
- repeatMode: repeatMode.persistedString,
769
+ repeatMode: repeatMode.stringValue,
759
770
  shuffleEnabled: shuffleEnabled,
760
771
  playbackSpeed: rate,
761
772
  ),
762
773
  )
763
774
  }
764
775
 
776
+ /// Applies the player settings a snapshot carries — the inverse of
777
+ /// `persistPlaybackState()`, and the twin of the Kotlin store's `restore()`.
778
+ ///
779
+ /// Call BEFORE building the queue: `setQueue` rebuilds the shuffle order
780
+ /// pinned to the starting track, so shuffle has to be set for that order to
781
+ /// be the one playback uses (and the mode events then precede the
782
+ /// queue-changed event, as on Android). An unrecognized repeat mode restores
783
+ /// as `.off` rather than failing the whole resume over one field; a
784
+ /// non-positive speed is rejected by `rate`'s own setter.
785
+ func restorePlaybackSettings(from state: PersistedPlaybackState) {
786
+ repeatMode = RepeatMode(fromString: state.repeatMode) ?? .off
787
+ shuffleEnabled = state.shuffleEnabled
788
+ rate = state.playbackSpeed
789
+ }
790
+
765
791
  /// Start a 5 s repeating save while playback is active. A previous task is
766
792
  /// cancelled first so there is never more than one running at a time.
767
793
  private func startPeriodicSave() {
@@ -781,17 +807,6 @@ extension TrackPlayer {
781
807
  }
782
808
  }
783
809
 
784
- private extension RepeatMode {
785
- /// String representation stored in `PersistedPlaybackState.repeatMode`.
786
- var persistedString: String {
787
- switch self {
788
- case .off: "off"
789
- case .track: "track"
790
- case .queue: "queue"
791
- }
792
- }
793
- }
794
-
795
810
  // MARK: - PlaybackEffectHandler
796
811
 
797
812
  extension TrackPlayer: PlaybackEffectHandler {
@@ -857,15 +872,23 @@ extension TrackPlayer: PlaybackEffectHandler {
857
872
  }
858
873
 
859
874
  func clearNowPlaying() {
860
- nowPlayingInfoController.clear()
875
+ nowPlayingUpdater.clearNowPlaying()
861
876
  }
862
877
 
878
+ // Both persist: a snapshot is otherwise only written on track load, on pause,
879
+ // and every 5s while playing, so a mode toggled while paused was lost — the
880
+ // cold-start resume would restore the value from before the toggle. Android
881
+ // persists on every setting change (`PlayerListener.onRepeatModeChanged` /
882
+ // `onShuffleModeEnabledChanged`); these are the same two edges, and the
883
+ // coordinator only calls them on a real change.
863
884
  func updateRemoteRepeatMode(_ mode: RepeatMode) {
864
885
  remoteCommandController.updateRepeatMode(mode)
886
+ persistPlaybackState()
865
887
  }
866
888
 
867
889
  func updateRemoteShuffleMode(_ enabled: Bool) {
868
890
  remoteCommandController.updateShuffleMode(enabled)
891
+ persistPlaybackState()
869
892
  }
870
893
 
871
894
  func updateSkipAvailability(canNext: Bool, canPrevious: Bool) {
@@ -884,8 +907,8 @@ extension TrackPlayer: TrackSelectionPlayer {}
884
907
  // MARK: - Queue Methods (thin forwarders)
885
908
 
886
909
  extension TrackPlayer {
887
- func replace(_ index: Int, _ track: Track) {
888
- coordinator.replace(index, track)
910
+ func replace(_ index: Int, _ track: Track) throws {
911
+ try coordinator.replace(index, track)
889
912
  }
890
913
 
891
914
  func setQueue(
@@ -11,13 +11,36 @@ final class NetworkMonitor: @unchecked Sendable {
11
11
  private let monitor: NWPathMonitor
12
12
  private let queue = DispatchQueue(label: "com.audiobrowser.networkmonitor")
13
13
 
14
- /// Current network connectivity state. Must only be set on main thread.
15
- private(set) var isOnline: Bool = false {
16
- didSet {
17
- if oldValue != isOnline {
18
- logger.notice("Network status changed: \(oldValue) -> \(self.isOnline)")
19
- onChanged?(isOnline)
20
- }
14
+ /// Guards `_isOnline` only. Never held across `onChanged` — see `apply`.
15
+ private let lock = NSLock()
16
+ private var _isOnline = false
17
+
18
+ /// Current network connectivity state. Readable from any thread: the value is
19
+ /// lock-protected rather than confined, so callers on the JS thread (Nitro's
20
+ /// `getOnline`) don't have to block on a main-queue hop to read a Bool.
21
+ ///
22
+ /// The broadcast is main-confined even though the value isn't — `onChanged`
23
+ /// fans out to the JS bridge, a now-playing re-render and a player reload, so
24
+ /// it must not run under the lock (reentrancy) or off the main actor.
25
+ var isOnline: Bool {
26
+ lock.lock()
27
+ defer { lock.unlock() }
28
+ return _isOnline
29
+ }
30
+
31
+ /// Stores a new status and, if it actually changed, broadcasts on main —
32
+ /// outside the lock.
33
+ private func apply(_ newStatus: Bool) {
34
+ lock.lock()
35
+ let changed = _isOnline != newStatus
36
+ let oldValue = _isOnline
37
+ _isOnline = newStatus
38
+ lock.unlock()
39
+
40
+ guard changed else { return }
41
+ logger.notice("Network status changed: \(oldValue) -> \(newStatus)")
42
+ DispatchQueue.main.async { [weak self] in
43
+ self?.onChanged?(newStatus)
21
44
  }
22
45
  }
23
46
 
@@ -31,29 +54,23 @@ final class NetworkMonitor: @unchecked Sendable {
31
54
  init() {
32
55
  monitor = NWPathMonitor()
33
56
 
34
- // Set up handler before starting
35
- monitor.pathUpdateHandler = { [weak self] path in
36
- let newStatus = path.status == .satisfied
37
- DispatchQueue.main.async { [weak self] in
38
- self?.isOnline = newStatus
39
- }
40
- }
41
-
42
- // Start monitoring
43
- monitor.start(queue: queue)
44
-
45
- // Read initial state after starting (currentPath is now valid)
57
+ // Seed BEFORE `start`. `start` publishes `self` to the monitor queue, so any
58
+ // write after it races the handler's locked write — and would clobber a real
59
+ // update with a `currentPath` the monitor hasn't finished evaluating. Nothing
60
+ // recovers from that: the handler has already fired, `onChanged` isn't wired
61
+ // until setupPlayer, and the stale value then drives `getOnline()`, the JS
62
+ // event, and RetryManager's offline parking until the next genuine path
63
+ // change. Seeding first is a best-effort guess; the handler fires shortly
64
+ // after `start` and is the authoritative last writer.
46
65
  let initialStatus = monitor.currentPath.status == .satisfied
47
66
  logger.notice("NetworkMonitor initialized, initial isOnline=\(initialStatus)")
48
- isOnline = initialStatus
49
- }
67
+ _isOnline = initialStatus
50
68
 
51
- // MARK: - Public Methods
69
+ monitor.pathUpdateHandler = { [weak self] path in
70
+ self?.apply(path.status == .satisfied)
71
+ }
52
72
 
53
- /// Gets the current network connectivity state.
54
- /// - Returns: true if device is online, false otherwise
55
- func getOnline() -> Bool {
56
- isOnline
73
+ monitor.start(queue: queue)
57
74
  }
58
75
 
59
76
  /// Stops monitoring and cleans up resources.
@@ -11,13 +11,21 @@ final class SFSymbolRenderer: @unchecked Sendable {
11
11
  @MainActor static let shared = SFSymbolRenderer()
12
12
  static let defaultCanvasSize = CGSize(width: 200, height: 200)
13
13
 
14
+ /// Bumped whenever the rendering below changes (symbol point-size factor,
15
+ /// centering, tinting, background fill). It is part of the cache filename
16
+ /// because `.cachesDirectory` survives app updates and the existence check is
17
+ /// the only cache validation — without a version key, every device that had
18
+ /// rendered a symbol once would keep serving the OLD pixels forever after a
19
+ /// render change.
20
+ private static let renderVersion = 1
21
+
14
22
  private let logger = Logger(subsystem: "com.audiobrowser", category: "SFSymbolRenderer")
15
23
  private let scale: CGFloat
16
24
  private let cacheDirectory: URL
17
25
 
18
26
  @MainActor
19
27
  private init() {
20
- scale = UIScreen.main.scale
28
+ scale = appDisplayScale
21
29
 
22
30
  let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
23
31
  cacheDirectory = caches.appendingPathComponent("sf-symbols", isDirectory: true)
@@ -45,7 +53,8 @@ final class SFSymbolRenderer: @unchecked Sendable {
45
53
  let safeArtwork = artworkValue.unicodeScalars
46
54
  .filter { CharacterSet.alphanumerics.contains($0) || $0 == "_" }
47
55
  .map(String.init).joined()
48
- let fileName = "\(safeArtwork)_\(Int(canvasSize.width))x\(Int(canvasSize.height))_\(Int(scale))x"
56
+ let fileName =
57
+ "\(safeArtwork)_\(Int(canvasSize.width))x\(Int(canvasSize.height))_\(Int(scale))x_v\(Self.renderVersion)"
49
58
  let fileURL = cacheDirectory.appendingPathComponent("\(fileName).png")
50
59
 
51
60
  if FileManager.default.fileExists(atPath: fileURL.path) {
@@ -139,4 +148,17 @@ extension UIColor {
139
148
  self.init(white: 0, alpha: 1)
140
149
  }
141
150
  }
151
+
152
+ /// Failable form for *declared* colors (`style.cardTint`): anything but a
153
+ /// 3- or 6-digit hex value is nil, so an unparseable declaration degrades
154
+ /// to "no declaration" (the system default) instead of rendering black —
155
+ /// matching how unknown enum values decode. `init(hex:)` keeps its lenient
156
+ /// black fallback for the `sf:` artwork params it has always served.
157
+ convenience init?(declaredHex hex: String) {
158
+ let digits = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
159
+ guard [3, 6].contains(digits.count),
160
+ digits.allSatisfy(\.isHexDigit)
161
+ else { return nil }
162
+ self.init(hex: hex)
163
+ }
142
164
  }
@@ -4,7 +4,14 @@ import UIKit
4
4
 
5
5
  /// Kingfisher processor that renders SVG data to UIImage using SwiftDraw.
6
6
  struct SVGProcessor: ImageProcessor {
7
- let identifier = "com.audiobrowser.svgprocessor"
7
+ /// Kingfisher keys its cache on `identifier` alone (`ImageCache.computedKey`),
8
+ /// and `.scaleFactor` never enters that key — so the render parameters have to,
9
+ /// or two different rasterizations of the same URL would collide. Today only
10
+ /// CarPlay rasterizes SVGs (one screen, one scale), so this is insurance
11
+ /// against a second call site rather than a live fix.
12
+ var identifier: String {
13
+ "com.audiobrowser.svgprocessor(\(size?.width ?? -1)x\(size?.height ?? -1)@\(scale))"
14
+ }
8
15
 
9
16
  /// Target size for rendering (in points). If nil, uses SVG's intrinsic size.
10
17
  let size: CGSize?
@@ -15,7 +22,7 @@ struct SVGProcessor: ImageProcessor {
15
22
  @MainActor
16
23
  init(size: CGSize? = nil, scale: CGFloat? = nil) {
17
24
  self.size = size
18
- self.scale = scale ?? UIScreen.main.scale
25
+ self.scale = scale ?? appDisplayScale
19
26
  }
20
27
 
21
28
  func process(item: ImageProcessItem, options _: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? {
@@ -222,6 +222,7 @@ function toNativeConfig(config) {
222
222
  singleTrack: config.singleTrack,
223
223
  handleTrackLoad: config.handleTrackLoad,
224
224
  androidControllerOfflineError: config.androidControllerOfflineError,
225
+ viewMoreTitle: config.viewMoreTitle,
225
226
  carPlayLoadingTitle: config.carPlayLoadingTitle,
226
227
  resolveAlbumPath: config.resolveAlbumPath,
227
228
  formatNavigationError: config.formatNavigationError