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.
- package/README.md +10 -0
- package/android/build.gradle +20 -1
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +5 -1
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +8 -0
- package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
- package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +27 -9
- 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/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 +8 -1
- package/android/src/test/java/com/audiobrowser/TestFixtures.kt +49 -3
- 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 +8 -8
- package/ios/Browser/BrowserManager.swift +106 -45
- package/ios/Browser/InertStyleDiagnostic.swift +279 -0
- package/ios/Browser/JsonModels.swift +81 -10
- 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/UIApplication+ActiveScene.swift +27 -0
- package/ios/Http/HttpClient.swift +8 -2
- package/ios/HybridAudioBrowser.swift +54 -11
- package/ios/Model/NitroTypeStubs.swift +60 -0
- 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/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/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/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/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-nodes.d.ts +104 -2
- package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
- 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__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 +12 -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 +6 -0
- 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 +6 -0
- 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/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/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.hpp +54 -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/GridTile.swift +44 -0
- package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
- 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++/SectionStyle.hpp +32 -2
- package/nitrogen/generated/shared/c++/TrackStyle.hpp +25 -2
- package/package.json +7 -2
- 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-nodes.ts +112 -2
- 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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
package com.audiobrowser.player
|
|
2
|
+
|
|
3
|
+
import androidx.media3.common.C
|
|
4
|
+
import org.junit.Assert.assertEquals
|
|
5
|
+
import org.junit.Assert.assertNotEquals
|
|
6
|
+
import org.junit.Assert.assertTrue
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Media3's `ShuffleOrder.cloneAndSet(insertionCount, startIndex)` discards the start index (the
|
|
11
|
+
* interface default clears and re-inserts from 0, and `DefaultShuffleOrder` does not override it),
|
|
12
|
+
* so `setMediaItems(items, startIndex, …)` leaves the starting track at a random shuffle position —
|
|
13
|
+
* last, for a 1-in-N share of queues, which reads as end-of-queue from the very first track.
|
|
14
|
+
*/
|
|
15
|
+
class ShuffleOrderLedByTest {
|
|
16
|
+
@Test
|
|
17
|
+
fun `the start index leads the order`() {
|
|
18
|
+
for (start in 0 until 6) {
|
|
19
|
+
val order = shuffleOrderLedBy(startIndex = start, count = 6)
|
|
20
|
+
assertEquals("start index must be at shuffle position 0", start, order.firstIndex)
|
|
21
|
+
assertNotEquals(
|
|
22
|
+
"a queue of 6 must have somewhere to advance to from its start track",
|
|
23
|
+
C.INDEX_UNSET.toLong(),
|
|
24
|
+
order.getNextIndex(start).toLong(),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun `every index appears exactly once`() {
|
|
31
|
+
val order = shuffleOrderLedBy(startIndex = 3, count = 8)
|
|
32
|
+
assertEquals(8, order.length)
|
|
33
|
+
val seen = mutableListOf<Int>()
|
|
34
|
+
var index = order.firstIndex
|
|
35
|
+
while (index != C.INDEX_UNSET) {
|
|
36
|
+
seen.add(index)
|
|
37
|
+
index = order.getNextIndex(index)
|
|
38
|
+
}
|
|
39
|
+
assertEquals((0 until 8).toList(), seen.sorted())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun `a single-track queue leads with its only track and ends there`() {
|
|
44
|
+
val order = shuffleOrderLedBy(startIndex = 0, count = 1)
|
|
45
|
+
assertEquals(0, order.firstIndex)
|
|
46
|
+
assertEquals(C.INDEX_UNSET, order.getNextIndex(0))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@Test
|
|
50
|
+
fun `an out-of-range start index is clamped rather than throwing`() {
|
|
51
|
+
assertEquals(4, shuffleOrderLedBy(startIndex = 99, count = 5).firstIndex)
|
|
52
|
+
assertEquals(0, shuffleOrderLedBy(startIndex = -3, count = 5).firstIndex)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun `an empty queue produces an empty order`() {
|
|
57
|
+
assertEquals(0, shuffleOrderLedBy(startIndex = 0, count = 0).length)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@Test
|
|
61
|
+
fun `the tail is actually shuffled`() {
|
|
62
|
+
// Not a strict guarantee of any single draw — but across many draws an
|
|
63
|
+
// unshuffled tail would be impossible to miss.
|
|
64
|
+
val tails =
|
|
65
|
+
(0 until 50)
|
|
66
|
+
.map { shuffleOrderLedBy(startIndex = 0, count = 8) }
|
|
67
|
+
.map { order ->
|
|
68
|
+
buildList {
|
|
69
|
+
var index = order.getNextIndex(order.firstIndex)
|
|
70
|
+
while (index != C.INDEX_UNSET) {
|
|
71
|
+
add(index)
|
|
72
|
+
index = order.getNextIndex(index)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
.toSet()
|
|
77
|
+
assertTrue("50 draws produced one identical tail — the order is not shuffled", tails.size > 1)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -2,10 +2,10 @@ package com.audiobrowser.util
|
|
|
2
2
|
|
|
3
3
|
import androidx.media.utils.MediaConstants
|
|
4
4
|
import com.audiobrowser.TestFixtures
|
|
5
|
+
import com.audiobrowser.TestFixtures.sectionStyle
|
|
6
|
+
import com.audiobrowser.TestFixtures.trackStyle
|
|
5
7
|
import com.audiobrowser.browser.styleResolvedSections
|
|
6
|
-
import com.margelo.nitro.audiobrowser.SectionStyle
|
|
7
8
|
import com.margelo.nitro.audiobrowser.StyleDisplay
|
|
8
|
-
import com.margelo.nitro.audiobrowser.TrackStyle
|
|
9
9
|
import org.junit.Assert.assertEquals
|
|
10
10
|
import org.junit.Assert.assertFalse
|
|
11
11
|
import org.junit.Test
|
|
@@ -23,7 +23,7 @@ class MediaExtrasBuilderTest {
|
|
|
23
23
|
private val track = TestFixtures.track()
|
|
24
24
|
|
|
25
25
|
private fun sectionStyle(display: StyleDisplay?) =
|
|
26
|
-
|
|
26
|
+
sectionStyle(display = display, artworkRendering = null, gridWrap = null)
|
|
27
27
|
|
|
28
28
|
@Test
|
|
29
29
|
fun `owning section title stamps the group-title hint`() {
|
|
@@ -65,7 +65,7 @@ class MediaExtrasBuilderTest {
|
|
|
65
65
|
@Test
|
|
66
66
|
fun `a track's own display never sets its item hint - display is positional`() {
|
|
67
67
|
val gridPromiseTrack =
|
|
68
|
-
track.copy(style =
|
|
68
|
+
track.copy(style = trackStyle(display = StyleDisplay.GRID, artworkRendering = null))
|
|
69
69
|
val section = TestFixtures.section(arrayOf(gridPromiseTrack), style = null)
|
|
70
70
|
val extras = MediaExtrasBuilder.build(gridPromiseTrack, section)
|
|
71
71
|
assertFalse(extras.containsKey(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM))
|
|
@@ -75,7 +75,7 @@ class MediaExtrasBuilderTest {
|
|
|
75
75
|
fun `a browsable track's declared display emits the parent-level promise`() {
|
|
76
76
|
val handle =
|
|
77
77
|
TestFixtures.browseTrack()
|
|
78
|
-
.copy(style =
|
|
78
|
+
.copy(style = trackStyle(display = StyleDisplay.GRID, artworkRendering = null))
|
|
79
79
|
val extras = MediaExtrasBuilder.build(handle)
|
|
80
80
|
val expected = MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
|
|
81
81
|
assertEquals(
|
|
@@ -99,7 +99,7 @@ class MediaExtrasBuilderTest {
|
|
|
99
99
|
@Test
|
|
100
100
|
fun `a playable track's display is inert - playables open no page`() {
|
|
101
101
|
val playable =
|
|
102
|
-
track.copy(style =
|
|
102
|
+
track.copy(style = trackStyle(display = StyleDisplay.GRID, artworkRendering = null))
|
|
103
103
|
val extras = MediaExtrasBuilder.build(playable)
|
|
104
104
|
assertFalse(extras.containsKey(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE))
|
|
105
105
|
assertFalse(extras.containsKey(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE))
|
|
@@ -128,7 +128,7 @@ class MediaExtrasBuilderTest {
|
|
|
128
128
|
arrayOf(track),
|
|
129
129
|
title = "Popular",
|
|
130
130
|
path = "/popular",
|
|
131
|
-
style =
|
|
131
|
+
style = sectionStyle(display = StyleDisplay.GRID, artworkRendering = null, gridWrap = false),
|
|
132
132
|
)
|
|
133
133
|
val navigation = TrackFactory.navigationTrack(section)
|
|
134
134
|
assertEquals(StyleDisplay.GRID, navigation.style?.display)
|
|
@@ -147,7 +147,7 @@ class MediaExtrasBuilderTest {
|
|
|
147
147
|
// narrower unit test stays green while the page tier goes dark.
|
|
148
148
|
val page =
|
|
149
149
|
TestFixtures.resolvedTrack(
|
|
150
|
-
style =
|
|
150
|
+
style = sectionStyle(display = StyleDisplay.GRID, artworkRendering = null, gridWrap = null),
|
|
151
151
|
sections = arrayOf(TestFixtures.section(arrayOf(track))),
|
|
152
152
|
)
|
|
153
153
|
|
|
@@ -67,8 +67,8 @@ final class BrowserManager {
|
|
|
67
67
|
// LRU cache for resolved content - keyed by path
|
|
68
68
|
private let contentCache = LRUCache<String, ResolvedTrack>(maxSize: 20)
|
|
69
69
|
|
|
70
|
-
//
|
|
71
|
-
private var
|
|
70
|
+
// Most recent search, keyed by `searchCacheKey`.
|
|
71
|
+
private var lastSearchKey: String?
|
|
72
72
|
private var lastSearchResults: [Track]?
|
|
73
73
|
|
|
74
74
|
// Resolver caching: request/browse thunks resolve once per content generation.
|
|
@@ -171,8 +171,8 @@ final class BrowserManager {
|
|
|
171
171
|
/// Optimistically reflects a local favorite toggle in the favorite set, keyed
|
|
172
172
|
/// by the track's identity (id when non-blank, else src). The consumer
|
|
173
173
|
/// reconciles `favoriteIds` to its canonical set on the next `setFavorites`;
|
|
174
|
-
/// `
|
|
175
|
-
/// heart stays responsive.
|
|
174
|
+
/// `hydrateFavorite` gives the same answer before and after, so the
|
|
175
|
+
/// now-playing heart stays responsive.
|
|
176
176
|
func setFavorited(identity: String, favorited: Bool) {
|
|
177
177
|
if favorited {
|
|
178
178
|
favoriteIds.insert(identity)
|
|
@@ -188,13 +188,10 @@ final class BrowserManager {
|
|
|
188
188
|
/// caller-set `favorited` wins — API/consumer-provided values are never
|
|
189
189
|
/// overwritten (matches Android and web).
|
|
190
190
|
func hydrateFavorite(_ track: Track) -> Track {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
/// Whether the track's identity is in the favorite set.
|
|
196
|
-
private func isFavorite(_ track: Track) -> Bool {
|
|
197
|
-
track.identity.map(favoriteIds.contains) ?? false
|
|
191
|
+
// `identity` bridges a std::string out of the C++ track, so it is bound
|
|
192
|
+
// once here rather than computed again to answer "is it favorited".
|
|
193
|
+
guard favoriteEnabled, track.favorited == nil, let identity = track.identity else { return track }
|
|
194
|
+
return track.copying(favorited: favoriteIds.contains(identity))
|
|
198
195
|
}
|
|
199
196
|
|
|
200
197
|
/// Hydrates favorites on all tracks of a resolved page. Pages reach
|
|
@@ -221,12 +218,21 @@ final class BrowserManager {
|
|
|
221
218
|
self.path = path
|
|
222
219
|
content = nil // Clear for loading state
|
|
223
220
|
|
|
224
|
-
|
|
221
|
+
do {
|
|
222
|
+
let resolved = try await resolve(path)
|
|
225
223
|
|
|
226
|
-
|
|
227
|
-
|
|
224
|
+
// Only apply result if this is still the current navigation
|
|
225
|
+
guard navigationId == currentNavigationId else { return }
|
|
228
226
|
|
|
229
|
-
|
|
227
|
+
content = resolved
|
|
228
|
+
} catch {
|
|
229
|
+
// A superseded navigation's failure is not the current page's failure.
|
|
230
|
+
// Every caller reports a throw unconditionally (and CarPlay presents it as
|
|
231
|
+
// a modal action sheet), so without this a slow /a that fails after a fast
|
|
232
|
+
// /b succeeded would show /a's error over /b's content.
|
|
233
|
+
guard navigationId == currentNavigationId else { return }
|
|
234
|
+
throw error
|
|
235
|
+
}
|
|
230
236
|
}
|
|
231
237
|
|
|
232
238
|
/// Resolves content for a path with optional caching.
|
|
@@ -248,7 +254,18 @@ final class BrowserManager {
|
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
/// Invalidates the content cache for a specific path.
|
|
251
|
-
|
|
257
|
+
///
|
|
258
|
+
/// Expects a *container* path. A contextual path (`…?__trackId=…`) addresses
|
|
259
|
+
/// a child, not a cache entry — entries are keyed by the stripped parent — so
|
|
260
|
+
/// passing one used to remove nothing at all, silently. Android `require`s the
|
|
261
|
+
/// same precondition; this throws so the misuse surfaces identically on both
|
|
262
|
+
/// platforms instead of no-opping on one.
|
|
263
|
+
func invalidateContentCache(_ path: String) throws {
|
|
264
|
+
guard !BrowserPathHelper.isContextual(path) else {
|
|
265
|
+
throw BrowserError.invalidConfiguration(
|
|
266
|
+
"invalidateContentCache() expects a container path, not a contextual path: \(path)",
|
|
267
|
+
)
|
|
268
|
+
}
|
|
252
269
|
contentCache.remove(path)
|
|
253
270
|
}
|
|
254
271
|
|
|
@@ -257,6 +274,11 @@ final class BrowserManager {
|
|
|
257
274
|
func clearContentCache() {
|
|
258
275
|
contentCache.clear()
|
|
259
276
|
layerGeneration += 1
|
|
277
|
+
// The search slot is content too: a locale switch or a re-auth changes
|
|
278
|
+
// titles, src, artwork and availability, and nothing else ever evicts it,
|
|
279
|
+
// so a repeated query returned pre-invalidation results forever.
|
|
280
|
+
lastSearchKey = nil
|
|
281
|
+
lastSearchResults = nil
|
|
260
282
|
}
|
|
261
283
|
|
|
262
284
|
/// Refreshes the current path by invalidating cache and re-resolving.
|
|
@@ -269,12 +291,19 @@ final class BrowserManager {
|
|
|
269
291
|
|
|
270
292
|
let currentPath = path
|
|
271
293
|
contentCache.remove(currentPath)
|
|
272
|
-
let resolved = try await resolve(currentPath, useCache: false)
|
|
273
294
|
|
|
274
|
-
|
|
275
|
-
|
|
295
|
+
do {
|
|
296
|
+
let resolved = try await resolve(currentPath, useCache: false)
|
|
276
297
|
|
|
277
|
-
|
|
298
|
+
// Only apply result if this is still the current navigation
|
|
299
|
+
guard navigationId == currentNavigationId else { return }
|
|
300
|
+
|
|
301
|
+
content = resolved
|
|
302
|
+
} catch {
|
|
303
|
+
// Superseded refreshes stay silent, for the same reason as `navigate`.
|
|
304
|
+
guard navigationId == currentNavigationId else { return }
|
|
305
|
+
throw error
|
|
306
|
+
}
|
|
278
307
|
}
|
|
279
308
|
|
|
280
309
|
private func resolveUncached(_ path: String) async throws -> ResolvedTrack {
|
|
@@ -295,6 +324,10 @@ final class BrowserManager {
|
|
|
295
324
|
// untitled section — ADR 0010), then validate and transform. The output
|
|
296
325
|
// never carries `children`.
|
|
297
326
|
if let sections = resolvedTrack.normalizedSections {
|
|
327
|
+
// Dev diagnostic (ADR 0011) — before the blocks are folded, while each
|
|
328
|
+
// level is still attributable. Every page passes here whatever its
|
|
329
|
+
// source, and only on a cache miss. No-op outside debug builds.
|
|
330
|
+
InertStyleDiagnostic.warn(path: path, pageStyle: resolvedTrack.style, sections: sections)
|
|
298
331
|
let transformed = try await transformSections(
|
|
299
332
|
sections, parentPath: path, routeEntry: routeEntry?.0,
|
|
300
333
|
)
|
|
@@ -574,16 +607,49 @@ final class BrowserManager {
|
|
|
574
607
|
)
|
|
575
608
|
}
|
|
576
609
|
|
|
577
|
-
///
|
|
578
|
-
///
|
|
579
|
-
///
|
|
610
|
+
/// The query params a structured search sends: `q` plus whichever filters are
|
|
611
|
+
/// set. Mirrors Android's `executeSearchApiRequest` and the web stub.
|
|
612
|
+
///
|
|
613
|
+
/// Single source of truth for "what distinguishes one search from another" —
|
|
614
|
+
/// the request sends exactly this, and the cache keys on exactly this, so a
|
|
615
|
+
/// field added here can't be forgotten in one place and silently serve one
|
|
616
|
+
/// search's results for another.
|
|
617
|
+
private static func searchQueryParams(_ params: SearchParams) -> [String: String] {
|
|
618
|
+
var query: [String: String] = ["q": params.query]
|
|
619
|
+
if let mode = params.mode { query["mode"] = mode.stringValue }
|
|
620
|
+
if params.reference == .my { query["reference"] = "my" }
|
|
621
|
+
if let genre = params.genre { query["genre"] = genre }
|
|
622
|
+
if let artist = params.artist { query["artist"] = artist }
|
|
623
|
+
if let album = params.album { query["album"] = album }
|
|
624
|
+
if let title = params.title { query["title"] = title }
|
|
625
|
+
if let playlist = params.playlist { query["playlist"] = playlist }
|
|
626
|
+
return query
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/// The cache identity of a search. Sorted so key order is stable, and
|
|
630
|
+
/// `\u{1}`-delimited so two different param sets can't serialize alike.
|
|
631
|
+
private static func searchCacheKey(_ params: SearchParams) -> String {
|
|
632
|
+
searchQueryParams(params)
|
|
633
|
+
.sorted { $0.key < $1.key }
|
|
634
|
+
.map { "\($0.key)=\($0.value)" }
|
|
635
|
+
.joined(separator: "\u{1}")
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/// Structured search, cached by the full parameter set.
|
|
639
|
+
///
|
|
640
|
+
/// Voice intents reuse `query` across different structures —
|
|
641
|
+
/// `MediaIntentCriteria` falls back to the structured value when there is no
|
|
642
|
+
/// spoken name, so "play jazz" (genre) and a text search for "jazz" arrive
|
|
643
|
+
/// with an identical `query` — hence `searchCacheKey` rather than `query`.
|
|
580
644
|
func search(_ params: SearchParams) async throws -> ResolvedTrack {
|
|
581
645
|
let query = params.query
|
|
646
|
+
let cacheKey = Self.searchCacheKey(params)
|
|
582
647
|
|
|
583
|
-
// Check cache -
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
648
|
+
// Check cache - hydrate favorites on the way out, since they may have
|
|
649
|
+
// changed since the search ran (the slot holds the raw results for exactly
|
|
650
|
+
// this reason — see below).
|
|
651
|
+
if cacheKey == lastSearchKey, let results = lastSearchResults {
|
|
652
|
+
return makeSearchResult(query: query, results: results)
|
|
587
653
|
}
|
|
588
654
|
|
|
589
655
|
guard let routes = config.routes else {
|
|
@@ -608,18 +674,9 @@ final class BrowserManager {
|
|
|
608
674
|
// Seed the search config's static path onto the base — a layer's static
|
|
609
675
|
// path never applies (carried from the base; only a transform may change
|
|
610
676
|
// it). Matches the web stub's fetchSearchResults and Android.
|
|
611
|
-
var queryParams: [String: String] = ["q": query]
|
|
612
|
-
if let mode = params.mode { queryParams["mode"] = mode.stringValue }
|
|
613
|
-
if params.reference == .my { queryParams["reference"] = "my" }
|
|
614
|
-
if let genre = params.genre { queryParams["genre"] = genre }
|
|
615
|
-
if let artist = params.artist { queryParams["artist"] = artist }
|
|
616
|
-
if let album = params.album { queryParams["album"] = album }
|
|
617
|
-
if let title = params.title { queryParams["title"] = title }
|
|
618
|
-
if let playlist = params.playlist { queryParams["playlist"] = playlist }
|
|
619
|
-
|
|
620
677
|
let request = try await buildApiRequest(
|
|
621
678
|
kind: nil, searchConfig, path: searchConfig.path ?? "", params: ["q": query],
|
|
622
|
-
initialQuery:
|
|
679
|
+
initialQuery: Self.searchQueryParams(params),
|
|
623
680
|
)
|
|
624
681
|
logger.debug("Searching via API: \(request.url)")
|
|
625
682
|
let jsonTracks: [JsonTrack] = try await httpClient.requestJson(request, as: [JsonTrack].self)
|
|
@@ -628,14 +685,15 @@ final class BrowserManager {
|
|
|
628
685
|
throw BrowserError.contentNotFound(path: Self.searchRoutePath)
|
|
629
686
|
}
|
|
630
687
|
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
688
|
+
// Cache the RAW results, never the hydrated ones: hydration is one-shot
|
|
689
|
+
// (`hydrateFavorite` skips a track whose `favorited` is already set), so a
|
|
690
|
+
// hydrated slot would serve the favorites as they were at first search
|
|
691
|
+
// forever. The content cache keys on the same rule — it stores the
|
|
692
|
+
// unhydrated resolve and hydrates in `resolve()`.
|
|
693
|
+
lastSearchKey = cacheKey
|
|
694
|
+
lastSearchResults = results
|
|
637
695
|
|
|
638
|
-
return makeSearchResult(query: query, results:
|
|
696
|
+
return makeSearchResult(query: query, results: results)
|
|
639
697
|
}
|
|
640
698
|
|
|
641
699
|
/// Search and return playable tracks for "play «X»" voice intents, drilling
|
|
@@ -653,13 +711,16 @@ final class BrowserManager {
|
|
|
653
711
|
}
|
|
654
712
|
}
|
|
655
713
|
|
|
714
|
+
/// Wraps raw search results as a resolved page, hydrating favorites on the
|
|
715
|
+
/// way out — the single exit for `search`, so an unhydrated search page can't
|
|
716
|
+
/// be emitted (the same funnel `resolve()` gets from `hydrateChildren`).
|
|
656
717
|
private func makeSearchResult(query: String, results: [Track]) -> ResolvedTrack {
|
|
657
718
|
// Search results are a flat list; as a resolved page they are one
|
|
658
719
|
// untitled section (ADR 0010).
|
|
659
720
|
ResolvedTrack(
|
|
660
721
|
path: BrowserPathHelper.createSearchPath(query),
|
|
661
722
|
style: nil,
|
|
662
|
-
sections: [.untitled(results)],
|
|
723
|
+
sections: [.untitled(results.map { hydrateFavorite($0) })],
|
|
663
724
|
children: nil,
|
|
664
725
|
carPlaySiriListButton: nil,
|
|
665
726
|
id: nil,
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import os.log
|
|
2
|
+
|
|
3
|
+
#if canImport(NitroModules)
|
|
4
|
+
import NitroModules
|
|
5
|
+
#endif
|
|
6
|
+
|
|
7
|
+
/// Dev diagnostic (ADR 0011): the declarations on a resolved page that can
|
|
8
|
+
/// never render.
|
|
9
|
+
///
|
|
10
|
+
/// The block model deliberately gave up compile-time invalid-combination
|
|
11
|
+
/// errors — combinations are not invalid, they are *inert*, the way
|
|
12
|
+
/// `grid-template` on a non-grid element is inert in CSS. This is the
|
|
13
|
+
/// recovery: at page resolution every declaration its own block makes
|
|
14
|
+
/// unreadable is reported once, with the effective context and the fix.
|
|
15
|
+
///
|
|
16
|
+
/// The line it draws, and never crosses: only **structural** inertness — a
|
|
17
|
+
/// declaration the resolved block itself renders unreadable, on every surface
|
|
18
|
+
/// (`gridWrap` outside a grid, `cardTint` with no card treatment in scope).
|
|
19
|
+
/// *Surface* inertness is never reported, because declarations are
|
|
20
|
+
/// aspirational: `imageShape` on Android Auto, or a card treatment on a
|
|
21
|
+
/// pre-26 CarPlay, is intended usage — the renderer drops what it can't draw.
|
|
22
|
+
///
|
|
23
|
+
/// Runs where every page passes regardless of its source (static config,
|
|
24
|
+
/// route callback, or fetched browse JSON), and only on a cache miss, so a
|
|
25
|
+
/// mistake is reported once per resolution rather than once per serve.
|
|
26
|
+
///
|
|
27
|
+
/// Debug builds only: `warn` is the whole seam, so a release build never even
|
|
28
|
+
/// walks the page (the trade is that a page served only to a Release build —
|
|
29
|
+
/// TestFlight, production browse JSON — reports nothing; reproduce it against
|
|
30
|
+
/// a debug build).
|
|
31
|
+
enum InertStyleDiagnostic {
|
|
32
|
+
/// Reports every inert declaration on a resolved page. The one gate — the
|
|
33
|
+
/// `#if` is here rather than at the call sites so callers read
|
|
34
|
+
/// unconditionally and the rule set stays free of build-configuration
|
|
35
|
+
/// knowledge.
|
|
36
|
+
static func warn(path: String, pageStyle: SectionStyle?, sections: [Section]) {
|
|
37
|
+
#if DEBUG
|
|
38
|
+
let logger = Logger(subsystem: "com.audiobrowser", category: "InertStyleDiagnostic")
|
|
39
|
+
for finding in findings(path: path, pageStyle: pageStyle, sections: sections) {
|
|
40
|
+
logger.warning("\(finding, privacy: .public)")
|
|
41
|
+
}
|
|
42
|
+
#endif
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// The declarations that carry a structural inertness condition.
|
|
46
|
+
///
|
|
47
|
+
/// Two keys of the block are deliberately absent — `display` and
|
|
48
|
+
/// `artworkRendering`, dispositioned below instead.
|
|
49
|
+
private enum Property: String, CaseIterable {
|
|
50
|
+
case gridWrap
|
|
51
|
+
case gridTile
|
|
52
|
+
case imageShape
|
|
53
|
+
case accessorySymbol
|
|
54
|
+
case cardTint
|
|
55
|
+
case cardImage
|
|
56
|
+
|
|
57
|
+
/// Item properties inherit (`track ?? section ?? page`) and so may also
|
|
58
|
+
/// be declared per track; container properties resolve by scope override
|
|
59
|
+
/// (`section ?? page`) and never reach an item.
|
|
60
|
+
var isItemProperty: Bool {
|
|
61
|
+
switch self {
|
|
62
|
+
case .gridWrap, .gridTile: false
|
|
63
|
+
case .imageShape, .accessorySymbol, .cardTint, .cardImage: true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Whether a value of this property, resolved into `context`, has any
|
|
68
|
+
/// rendering to affect. Each case is the spec's own "inert unless" clause.
|
|
69
|
+
func isInert(in context: Context) -> Bool {
|
|
70
|
+
switch self {
|
|
71
|
+
case .gridWrap, .gridTile: !context.isGrid
|
|
72
|
+
case .imageShape: !context.isGrid || context.isCard
|
|
73
|
+
case .accessorySymbol: context.isCard
|
|
74
|
+
case .cardTint, .cardImage: !context.isCard
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// The rule the declaration falls foul of, stated positively.
|
|
79
|
+
var rule: String {
|
|
80
|
+
switch self {
|
|
81
|
+
case .gridWrap, .gridTile: "`\(rawValue)` renders only in a grid"
|
|
82
|
+
case .imageShape: "only plain and condensed tiles have a shape"
|
|
83
|
+
case .accessorySymbol: "card tiles have no accessory slot"
|
|
84
|
+
case .cardTint, .cardImage: "the card properties render only under `gridTile: 'card'`"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
var hint: String {
|
|
89
|
+
switch self {
|
|
90
|
+
case .gridWrap, .gridTile:
|
|
91
|
+
"Declare `display: 'grid'` on the section or the page, or drop `\(rawValue)`."
|
|
92
|
+
case .imageShape:
|
|
93
|
+
"Render the items as plain or condensed grid tiles, or drop `imageShape`."
|
|
94
|
+
case .accessorySymbol:
|
|
95
|
+
"Use `gridTile: 'plain'` or `'condensed'`, or drop `accessorySymbol`."
|
|
96
|
+
case .cardTint, .cardImage:
|
|
97
|
+
"Declare `display: 'grid'` with `gridTile: 'card'`, or drop `\(rawValue)`."
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Iterated per section; `allCases` is computed, so read it once.
|
|
103
|
+
private static let allProperties = Property.allCases
|
|
104
|
+
|
|
105
|
+
/// The rest of the block's keys, dispositioned rather than ruled on:
|
|
106
|
+
/// `artworkRendering` has no structural inertness condition (artwork renders
|
|
107
|
+
/// in every layout), and `display` is positional — inert only in the one
|
|
108
|
+
/// position that opens no page (see `playableDisplayFinding`). Declared here
|
|
109
|
+
/// so the spec-level completeness test can hold this file to the whole
|
|
110
|
+
/// vocabulary: every key of the block is a rule, always renderable, or
|
|
111
|
+
/// positional, and a newly added property is a decision rather than an
|
|
112
|
+
/// omission.
|
|
113
|
+
static let alwaysRenderable = ["artworkRendering"]
|
|
114
|
+
static let positional = ["display"]
|
|
115
|
+
|
|
116
|
+
/// A section's effective container context: the two container values every
|
|
117
|
+
/// inertness condition is stated against.
|
|
118
|
+
private struct Context {
|
|
119
|
+
let isGrid: Bool
|
|
120
|
+
let tile: GridTile
|
|
121
|
+
|
|
122
|
+
/// Folded through `StyleResolver` rather than re-deriving the scope
|
|
123
|
+
/// override: the diagnostic predicts what a renderer will do with these
|
|
124
|
+
/// declarations, so it has to resolve them the way the renderers do.
|
|
125
|
+
init(section: SectionStyle?, page: SectionStyle?) {
|
|
126
|
+
let folded = StyleResolver.sectionStyle(section: section, page: page)
|
|
127
|
+
isGrid = folded.display == .grid
|
|
128
|
+
tile = folded.gridTile ?? .plain
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Whether the card treatment is what this container renders — the
|
|
132
|
+
/// condition the `card*` family is named after.
|
|
133
|
+
var isCard: Bool { isGrid && tile == .card }
|
|
134
|
+
|
|
135
|
+
/// The effective style, for the message — in the wire spelling the author
|
|
136
|
+
/// types (`stringValue` is the generated enum's own mapping). `gridTile`
|
|
137
|
+
/// is quoted only where a grid gives it meaning: naming it on a list would
|
|
138
|
+
/// be advice to fix the wrong property.
|
|
139
|
+
var described: String {
|
|
140
|
+
isGrid ? "display 'grid', gridTile '\(tile.stringValue)'" : "display 'list'"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// Every inert declaration on a resolved page, as ready-to-log messages —
|
|
145
|
+
/// pure, so the whole rule set is testable off-device; the caller logs.
|
|
146
|
+
///
|
|
147
|
+
/// - Parameters:
|
|
148
|
+
/// - path: the resolved path, named in every message.
|
|
149
|
+
/// - pageStyle: the page's own block (`ResolvedTrack.style`).
|
|
150
|
+
/// - sections: the page's normalized sections, blocks *unfolded* — the
|
|
151
|
+
/// diagnostic attributes each finding to the level that declared it, so
|
|
152
|
+
/// it must see the levels before `StyleResolver` merges them.
|
|
153
|
+
static func findings(path: String, pageStyle: SectionStyle?, sections: [Section]) -> [String] {
|
|
154
|
+
var findings: [String] = []
|
|
155
|
+
// A page-level declaration is judged across the whole page: it is live if
|
|
156
|
+
// it renders in any section it reaches, however many others ignore it.
|
|
157
|
+
var pageReached: Set<Property> = []
|
|
158
|
+
var pageRendered: Set<Property> = []
|
|
159
|
+
|
|
160
|
+
for (index, section) in sections.enumerated() {
|
|
161
|
+
let context = Context(section: section.style, page: pageStyle)
|
|
162
|
+
let sectionLabel = label(section, index: index)
|
|
163
|
+
|
|
164
|
+
for property in allProperties {
|
|
165
|
+
let inert = property.isInert(in: context)
|
|
166
|
+
|
|
167
|
+
if declared(property, in: section.style) {
|
|
168
|
+
if inert {
|
|
169
|
+
findings.append(
|
|
170
|
+
message(
|
|
171
|
+
property, path: path, owner: sectionLabel,
|
|
172
|
+
scope: "its effective style is \(context.described)",
|
|
173
|
+
))
|
|
174
|
+
}
|
|
175
|
+
} else if declared(property, in: pageStyle) {
|
|
176
|
+
// The section takes the page's value (nothing closer overrode it).
|
|
177
|
+
pageReached.insert(property)
|
|
178
|
+
if !inert { pageRendered.insert(property) }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
guard property.isItemProperty, inert else { continue }
|
|
182
|
+
let tracks = section.children.count(where: { declared(property, in: $0.style) })
|
|
183
|
+
if tracks > 0 {
|
|
184
|
+
findings.append(
|
|
185
|
+
message(
|
|
186
|
+
property, path: path, owner: "\(trackCount(tracks)) in \(sectionLabel)",
|
|
187
|
+
scope: "the section's effective style is \(context.described)",
|
|
188
|
+
))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if let finding = playableDisplayFinding(section, path: path, label: sectionLabel) {
|
|
193
|
+
findings.append(finding)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for property in allProperties
|
|
198
|
+
where pageReached.contains(property) && !pageRendered.contains(property)
|
|
199
|
+
{
|
|
200
|
+
findings.append(
|
|
201
|
+
message(
|
|
202
|
+
property, path: path, owner: "the page block",
|
|
203
|
+
scope: "no section that inherits it renders one",
|
|
204
|
+
))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return findings
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// `display` is positional, so it is never inert *in* a container — but on
|
|
211
|
+
/// a track that renders playable (`src` wins the rendering) it promises the
|
|
212
|
+
/// layout of a page that never opens.
|
|
213
|
+
private static func playableDisplayFinding(
|
|
214
|
+
_ section: Section, path: String, label: String,
|
|
215
|
+
) -> String? {
|
|
216
|
+
let tracks = section.children.count(where: { $0.src != nil && $0.style?.display != nil })
|
|
217
|
+
guard tracks > 0 else { return nil }
|
|
218
|
+
return message(
|
|
219
|
+
path: path, owner: "\(trackCount(tracks)) in \(label)", key: "display",
|
|
220
|
+
reason: "`display` is the layout promise for the page a track opens, and a track "
|
|
221
|
+
+ "rendered playable opens none",
|
|
222
|
+
hint: "Drop `display`, or give the track a `path` if it should be browsable.",
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private static func message(
|
|
227
|
+
_ property: Property, path: String, owner: String, scope: String,
|
|
228
|
+
) -> String {
|
|
229
|
+
message(
|
|
230
|
+
path: path, owner: owner, key: property.rawValue,
|
|
231
|
+
reason: "\(property.rule), but \(scope)", hint: property.hint,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// The one message skeleton — every finding names the page, the declaration,
|
|
236
|
+
/// who declared it, why it can't render, and what to do about it.
|
|
237
|
+
private static func message(
|
|
238
|
+
path: String, owner: String, key: String, reason: String, hint: String,
|
|
239
|
+
) -> String {
|
|
240
|
+
"Inert style declaration on page '\(path)': `style.\(key)` on \(owner) "
|
|
241
|
+
+ "never renders — \(reason). \(hint)"
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private static func label(_ section: Section, index: Int) -> String {
|
|
245
|
+
if let title = section.title, !title.isEmpty { return "section '\(title)'" }
|
|
246
|
+
return "section #\(index + 1)"
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private static func trackCount(_ count: Int) -> String {
|
|
250
|
+
"\(count) track\(count == 1 ? "" : "s")"
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Nitro flattens the spec's `extends`, so the two blocks share no type —
|
|
254
|
+
// hence two lookups over the same key set rather than one protocol.
|
|
255
|
+
|
|
256
|
+
private static func declared(_ property: Property, in style: SectionStyle?) -> Bool {
|
|
257
|
+
guard let style else { return false }
|
|
258
|
+
switch property {
|
|
259
|
+
case .gridWrap: return style.gridWrap != nil
|
|
260
|
+
case .gridTile: return style.gridTile != nil
|
|
261
|
+
case .imageShape: return style.imageShape != nil
|
|
262
|
+
case .accessorySymbol: return style.accessorySymbol != nil
|
|
263
|
+
case .cardTint: return style.cardTint != nil
|
|
264
|
+
case .cardImage: return style.cardImage != nil
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private static func declared(_ property: Property, in style: TrackStyle?) -> Bool {
|
|
269
|
+
guard let style else { return false }
|
|
270
|
+
switch property {
|
|
271
|
+
// Container properties have no Track slot to be declared in.
|
|
272
|
+
case .gridWrap, .gridTile: return false
|
|
273
|
+
case .imageShape: return style.imageShape != nil
|
|
274
|
+
case .accessorySymbol: return style.accessorySymbol != nil
|
|
275
|
+
case .cardTint: return style.cardTint != nil
|
|
276
|
+
case .cardImage: return style.cardImage != nil
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|