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.
- package/README.md +10 -0
- package/android/build.gradle +20 -1
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +8 -1
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +52 -0
- package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
- package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +27 -11
- package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +1 -1
- package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +11 -0
- package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +11 -1
- package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +21 -22
- package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +17 -2
- package/android/src/main/java/com/audiobrowser/player/Player.kt +3 -0
- package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
- package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +73 -0
- package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +16 -6
- package/android/src/main/res/values/strings.xml +37 -2
- package/android/src/test/java/com/audiobrowser/TestFixtures.kt +50 -5
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +9 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +3 -2
- package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
- package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +56 -0
- package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +4 -4
- package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +77 -10
- package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +2 -2
- package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
- package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +10 -9
- package/ios/Browser/BrowserManager.swift +106 -45
- package/ios/Browser/InertStyleDiagnostic.swift +279 -0
- package/ios/Browser/JsonModels.swift +81 -15
- package/ios/Browser/SectionPresentation.swift +30 -0
- package/ios/Browser/StyleResolver.swift +11 -0
- package/ios/Browser/TabBarEntries.swift +7 -1
- package/ios/CarPlay/CarPlayController.swift +139 -35
- package/ios/CarPlay/CarPlayListItemFactory.swift +99 -27
- package/ios/Extension/Section+Copying.swift +1 -3
- package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
- package/ios/Http/HttpClient.swift +8 -2
- package/ios/HybridAudioBrowser.swift +56 -12
- package/ios/Model/NitroTypeStubs.swift +60 -1
- package/ios/Player/MediaIntentCriteria.swift +13 -5
- package/ios/Player/MediaLoader.swift +33 -9
- package/ios/Player/NowPlayingUpdater.swift +53 -5
- package/ios/Player/PlaybackCoordinator.swift +11 -4
- package/ios/Player/PlaybackTimer.swift +8 -2
- package/ios/Player/QueueManager.swift +36 -10
- package/ios/Player/ShuffleOrder.swift +23 -0
- package/ios/Player/SleepTimerHandling.swift +13 -0
- package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
- package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
- package/ios/Tests/JsonModelsTests.swift +76 -1
- package/ios/Tests/QueueManagerTests.swift +72 -6
- package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
- package/ios/Tests/ResolvedTrackCopyingTests.swift +2 -2
- package/ios/Tests/SectionPresentationTests.swift +29 -0
- package/ios/Tests/StyleResolverTests.swift +60 -0
- package/ios/Tests/Support/TestTypes.swift +7 -0
- package/ios/TrackPlayer.swift +57 -34
- package/ios/Util/NetworkMonitor.swift +43 -26
- package/ios/Util/SFSymbolRenderer.swift +24 -2
- package/ios/Util/SVGProcessor.swift +9 -2
- package/lib/commonjs/features/browser-config.js +1 -0
- package/lib/commonjs/features/browser-config.js.map +1 -1
- package/lib/commonjs/features/playback/progress.js +2 -1
- package/lib/commonjs/features/playback/progress.js.map +1 -1
- package/lib/commonjs/features/playback/rate.js +8 -1
- package/lib/commonjs/features/playback/rate.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
- package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
- package/lib/module/features/browser-config.js +1 -0
- package/lib/module/features/browser-config.js.map +1 -1
- package/lib/module/features/playback/progress.js +2 -1
- package/lib/module/features/playback/progress.js.map +1 -1
- package/lib/module/features/playback/rate.js +8 -1
- package/lib/module/features/playback/rate.js.map +1 -1
- package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
- package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
- package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
- package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
- package/lib/typescript/src/features/playback/rate.d.ts +8 -1
- package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
- package/lib/typescript/src/types/browser-native.d.ts +2 -1
- package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
- package/lib/typescript/src/types/browser-nodes.d.ts +116 -9
- package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
- package/lib/typescript/src/types/browser.d.ts +36 -0
- package/lib/typescript/src/types/browser.d.ts.map +1 -1
- package/nitrogen/generated/android/AudioBrowserOnLoad.cpp +2 -0
- package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +4 -0
- package/nitrogen/generated/android/c++/JBrowseResult.hpp +6 -0
- package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
- package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__string__.hpp +102 -0
- 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
- package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +6 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +4 -0
- package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
- package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +13 -0
- package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
- package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +4 -0
- package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +20 -1
- package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +6 -0
- package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +4 -0
- package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JResolvedTrack.hpp +6 -0
- package/nitrogen/generated/android/c++/JSection.hpp +7 -5
- package/nitrogen/generated/android/c++/JSectionStyle.hpp +30 -3
- package/nitrogen/generated/android/c++/JTrack.hpp +4 -0
- package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +4 -0
- package/nitrogen/generated/android/c++/JTrackStyle.hpp +24 -3
- package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +6 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Func_std__shared_ptr_Promise_std__string__.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +7 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Section.kt +2 -5
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +18 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +15 -3
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.cpp +9 -0
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +104 -0
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +9 -0
- package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +9 -0
- package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
- package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__string__.swift +54 -0
- package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
- package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
- package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +48 -1
- package/nitrogen/generated/ios/swift/Section.swift +1 -19
- package/nitrogen/generated/ios/swift/SectionStyle.swift +70 -1
- package/nitrogen/generated/ios/swift/TrackStyle.swift +59 -1
- package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
- package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
- package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
- package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +6 -2
- package/nitrogen/generated/shared/c++/Section.hpp +1 -5
- package/nitrogen/generated/shared/c++/SectionStyle.hpp +32 -2
- package/nitrogen/generated/shared/c++/TrackStyle.hpp +25 -2
- package/package.json +7 -2
- package/src/features/browser-config.ts +1 -0
- package/src/features/playback/progress.ts +2 -1
- package/src/features/playback/rate.ts +8 -1
- package/src/style-resolution.test.ts +72 -1
- package/src/types/browser-native.ts +5 -1
- package/src/types/browser-nodes.ts +124 -9
- package/src/types/browser.ts +38 -0
- package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
- package/src/web/TrackPlayer/QueueManager.ts +14 -0
- package/website/guide/android-auto.md +114 -0
- package/website/guide/android-certificates.md +409 -0
- package/website/guide/artwork.md +255 -0
- package/website/guide/audio-output.md +135 -0
- package/website/guide/automotive.md +86 -0
- package/website/guide/basic-usage.md +151 -0
- package/website/guide/battery.md +115 -0
- package/website/guide/browser.md +581 -0
- package/website/guide/carplay.md +620 -0
- package/website/guide/configuration.md +173 -0
- package/website/guide/equalizer.md +231 -0
- package/website/guide/errors.md +310 -0
- package/website/guide/favorites.md +154 -0
- package/website/guide/gate.md +143 -0
- package/website/guide/getting-started.md +46 -0
- package/website/guide/hooks.md +238 -0
- package/website/guide/live-streams.md +125 -0
- package/website/guide/metadata.md +239 -0
- package/website/guide/migrating-from-track-player.md +244 -0
- package/website/guide/native-callback-fetch.md +127 -0
- package/website/guide/network.md +86 -0
- package/website/guide/now-playing.md +175 -0
- package/website/guide/playback.md +328 -0
- package/website/guide/queue.md +300 -0
- package/website/guide/remote-controls.md +256 -0
- package/website/guide/search.md +255 -0
- package/website/guide/sleep-timer.md +268 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
lazy var
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
lazy var
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
lazy var
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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(
|
|
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
|
}
|