react-native-audio-browser 0.2.0-next.20 → 0.2.0-next.22

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 CHANGED
@@ -172,7 +172,7 @@ Full guides and the complete [API Reference](https://audiobrowser.dev/api/) live
172
172
 
173
173
  **Troubleshooting** — [Networking in native callbacks](https://audiobrowser.dev/guide/native-callback-fetch) · [Android SSL / trust anchors](https://audiobrowser.dev/guide/android-certificates)
174
174
 
175
- The example app in [`apps/example-native`](https://github.com/radio-garden/react-native-audio-browser/tree/main/apps/example-native) browses archive.org's audio collection — in-app, on CarPlay, and on Android Auto — with search, favorites, and the gate.
175
+ The example app in [`apps/example-native`](https://github.com/radio-garden/react-native-audio-browser/tree/main/apps/example-native) browses archive.org's audio collection — in-app, on CarPlay, and on Android Auto — with search, favorites, and the gate. Its **Style Lab** tab renders every CarPlay `style` declaration, each shelf titled with the block it draws ([source](https://github.com/radio-garden/react-native-audio-browser/blob/main/apps/example-native/src/utils/style-lab.ts)).
176
176
 
177
177
  ### For coding agents
178
178
 
@@ -66,8 +66,8 @@ object InertStyleDiagnostic {
66
66
  when (this) {
67
67
  GRID_WRAP,
68
68
  GRID_TILE -> !context.isGrid
69
- IMAGE_SHAPE -> !context.isGrid || context.isCard
70
- ACCESSORY_SYMBOL -> context.isCard
69
+ IMAGE_SHAPE -> !context.isShaped
70
+ ACCESSORY_SYMBOL -> context.isGrid && !context.isShaped
71
71
  CARD_TINT,
72
72
  CARD_IMAGE -> !context.isCard
73
73
  }
@@ -78,8 +78,9 @@ object InertStyleDiagnostic {
78
78
  when (this) {
79
79
  GRID_WRAP,
80
80
  GRID_TILE -> "`$key` renders only in a grid"
81
- IMAGE_SHAPE -> "only plain and condensed tiles have a shape"
82
- ACCESSORY_SYMBOL -> "card tiles have no accessory slot"
81
+ IMAGE_SHAPE -> "only image and condensed tiles have a shape"
82
+ ACCESSORY_SYMBOL ->
83
+ "among grid tiles only image and condensed ones have an accessory slot"
83
84
  CARD_TINT,
84
85
  CARD_IMAGE -> "the card properties render only under `gridTile: 'card'`"
85
86
  }
@@ -89,8 +90,9 @@ object InertStyleDiagnostic {
89
90
  when (this) {
90
91
  GRID_WRAP,
91
92
  GRID_TILE -> "Declare `display: 'grid'` on the section or the page, or drop `$key`."
92
- IMAGE_SHAPE -> "Render the items as plain or condensed grid tiles, or drop `imageShape`."
93
- ACCESSORY_SYMBOL -> "Use `gridTile: 'plain'` or `'condensed'`, or drop `accessorySymbol`."
93
+ IMAGE_SHAPE -> "Use `gridTile: 'image'` or `'condensed'` in a grid, or drop `imageShape`."
94
+ ACCESSORY_SYMBOL ->
95
+ "Use `gridTile: 'image'` or `'condensed'`, or a list, or drop `accessorySymbol`."
94
96
  CARD_TINT,
95
97
  CARD_IMAGE -> "Declare `display: 'grid'` with `gridTile: 'card'`, or drop `$key`."
96
98
  }
@@ -146,9 +148,28 @@ object InertStyleDiagnostic {
146
148
 
147
149
  /**
148
150
  * Whether the card treatment is what this container renders — the `card*` family's condition.
151
+ * Exhaustive, like [isShaped]: a new tile kind has to be ruled on here.
149
152
  */
150
153
  val isCard: Boolean
151
- get() = isGrid && tile == GridTile.CARD
154
+ get() =
155
+ isGrid &&
156
+ when (tile) {
157
+ GridTile.CARD -> true
158
+ GridTile.PLAIN,
159
+ GridTile.IMAGE,
160
+ GridTile.CONDENSED -> false
161
+ }
162
+
163
+ /** Whether this container renders a tile family with a shape and an accessory slot. */
164
+ val isShaped: Boolean
165
+ get() =
166
+ isGrid &&
167
+ when (tile) {
168
+ GridTile.IMAGE,
169
+ GridTile.CONDENSED -> true
170
+ GridTile.PLAIN,
171
+ GridTile.CARD -> false
172
+ }
152
173
 
153
174
  /**
154
175
  * The effective style, for the message — in the wire spelling the author types ([toWireString]
@@ -82,6 +82,7 @@ internal fun String?.toImageShape(): ImageShape? =
82
82
  internal fun GridTile.toWireString(): String =
83
83
  when (this) {
84
84
  GridTile.PLAIN -> "plain"
85
+ GridTile.IMAGE -> "image"
85
86
  GridTile.CARD -> "card"
86
87
  GridTile.CONDENSED -> "condensed"
87
88
  }
@@ -89,6 +90,7 @@ internal fun GridTile.toWireString(): String =
89
90
  internal fun String?.toGridTile(): GridTile? =
90
91
  when (this?.takeIf { it.isNotEmpty() }?.lowercase()) {
91
92
  "plain" -> GridTile.PLAIN
93
+ "image" -> GridTile.IMAGE
92
94
  "card" -> GridTile.CARD
93
95
  "condensed" -> GridTile.CONDENSED
94
96
  else -> null
@@ -100,6 +100,7 @@ class InertStyleDiagnosticTest {
100
100
  @Test
101
101
  fun `imageShape needs a shaped tile`() {
102
102
  val list = sectionStyle(imageShape = ImageShape.CIRCULAR)
103
+ val plain = sectionStyle(display = StyleDisplay.GRID, imageShape = ImageShape.CIRCULAR)
103
104
  val cards =
104
105
  sectionStyle(
105
106
  display = StyleDisplay.GRID,
@@ -112,26 +113,49 @@ class InertStyleDiagnosticTest {
112
113
  gridTile = GridTile.CONDENSED,
113
114
  imageShape = ImageShape.CIRCULAR,
114
115
  )
116
+ val image =
117
+ sectionStyle(
118
+ display = StyleDisplay.GRID,
119
+ gridTile = GridTile.IMAGE,
120
+ imageShape = ImageShape.CIRCULAR,
121
+ )
115
122
  assertEquals(1, findings(listOf(section(style = list))).size)
123
+ assertEquals(1, findings(listOf(section(style = plain))).size)
116
124
  assertEquals(1, findings(listOf(section(style = cards))).size)
117
- // Plain and condensed tiles both take a shape.
118
125
  assertTrue(findings(listOf(section(style = condensed))).isEmpty())
126
+ assertTrue(findings(listOf(section(style = image))).isEmpty())
119
127
  }
120
128
 
121
129
  @Test
122
- fun `accessorySymbol is inert only on cards`() {
130
+ fun `accessorySymbol needs an accessory slot`() {
123
131
  val rows = sectionStyle(accessorySymbol = "lock.fill")
132
+ val condensed =
133
+ sectionStyle(
134
+ display = StyleDisplay.GRID,
135
+ gridTile = GridTile.CONDENSED,
136
+ accessorySymbol = "lock.fill",
137
+ )
138
+ val image =
139
+ sectionStyle(
140
+ display = StyleDisplay.GRID,
141
+ gridTile = GridTile.IMAGE,
142
+ accessorySymbol = "lock.fill",
143
+ )
144
+ val plain = sectionStyle(display = StyleDisplay.GRID, accessorySymbol = "lock.fill")
124
145
  val cards =
125
146
  sectionStyle(
126
147
  display = StyleDisplay.GRID,
127
148
  gridTile = GridTile.CARD,
128
149
  accessorySymbol = "lock.fill",
129
150
  )
130
- // List rows draw accessories; card elements have no slot for one.
131
151
  assertTrue(findings(listOf(section(style = rows))).isEmpty())
132
- val found = findings(listOf(section(style = cards)))
133
- assertEquals(1, found.size)
134
- assertTrue(found[0], found[0].contains("no accessory slot"))
152
+ assertTrue(findings(listOf(section(style = condensed))).isEmpty())
153
+ assertTrue(findings(listOf(section(style = image))).isEmpty())
154
+ for (style in listOf(plain, cards)) {
155
+ val found = findings(listOf(section(style = style)))
156
+ assertEquals(1, found.size)
157
+ assertTrue(found[0], found[0].contains("accessory slot"))
158
+ }
135
159
  }
136
160
 
137
161
  @Test
@@ -167,10 +191,10 @@ class InertStyleDiagnosticTest {
167
191
  }
168
192
 
169
193
  @Test
170
- fun `a track's own shape is live in a plain grid`() {
194
+ fun `a track's own shape is live in a shaped grid`() {
171
195
  val grid =
172
196
  section(
173
- style = sectionStyle(display = StyleDisplay.GRID),
197
+ style = sectionStyle(display = StyleDisplay.GRID, gridTile = GridTile.IMAGE),
174
198
  children = arrayOf(track(style = trackStyle(imageShape = ImageShape.CIRCULAR))),
175
199
  )
176
200
  assertTrue(findings(listOf(grid)).isEmpty())
@@ -1,5 +1,7 @@
1
1
  package com.audiobrowser.browser
2
2
 
3
+ import com.audiobrowser.util.toGridTile
4
+ import com.audiobrowser.util.toWireString
3
5
  import com.margelo.nitro.audiobrowser.ArtworkRendering
4
6
  import com.margelo.nitro.audiobrowser.CardImage
5
7
  import com.margelo.nitro.audiobrowser.GridTile
@@ -134,6 +136,25 @@ class JsonStyleTest {
134
136
  assertEquals(CardImage.BACKGROUND, style?.cardImage)
135
137
  }
136
138
 
139
+ @Test
140
+ fun `decodes the image family`() {
141
+ val resolved =
142
+ json.decodeFromString<JsonResolvedTrack>(
143
+ """{"path":"/home","title":"Home","sections":[
144
+ {"style":{"display":"grid","gridTile":"image","imageShape":"circular"},
145
+ "children":[{"title":"C","src":"s"}]}
146
+ ]}"""
147
+ )
148
+ val style = resolved.toNitro().sections?.first()?.style
149
+ assertEquals(GridTile.IMAGE, style?.gridTile)
150
+ assertEquals(ImageShape.CIRCULAR, style?.imageShape)
151
+ }
152
+
153
+ @Test
154
+ fun `every gridTile survives the wire`() {
155
+ GridTile.entries.forEach { assertEquals(it, it.toWireString().toGridTile()) }
156
+ }
157
+
137
158
  @Test
138
159
  fun `unknown gridTile decodes as no declaration`() {
139
160
  val resolved =
@@ -132,6 +132,12 @@ class StyleResolverTest {
132
132
  page = sectionStyle(gridTile = GridTile.CARD),
133
133
  )
134
134
  assertEquals(GridTile.PLAIN, overridden.gridTile)
135
+ val image =
136
+ StyleResolver.sectionStyle(
137
+ section = sectionStyle(gridTile = GridTile.IMAGE),
138
+ page = sectionStyle(gridTile = GridTile.CARD),
139
+ )
140
+ assertEquals(GridTile.IMAGE, image.gridTile)
135
141
  }
136
142
 
137
143
  @Test
@@ -69,8 +69,8 @@ enum InertStyleDiagnostic {
69
69
  func isInert(in context: Context) -> Bool {
70
70
  switch self {
71
71
  case .gridWrap, .gridTile: !context.isGrid
72
- case .imageShape: !context.isGrid || context.isCard
73
- case .accessorySymbol: context.isCard
72
+ case .imageShape: !context.isShaped
73
+ case .accessorySymbol: context.isGrid && !context.isShaped
74
74
  case .cardTint, .cardImage: !context.isCard
75
75
  }
76
76
  }
@@ -79,8 +79,8 @@ enum InertStyleDiagnostic {
79
79
  var rule: String {
80
80
  switch self {
81
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"
82
+ case .imageShape: "only image and condensed tiles have a shape"
83
+ case .accessorySymbol: "among grid tiles only image and condensed ones have an accessory slot"
84
84
  case .cardTint, .cardImage: "the card properties render only under `gridTile: 'card'`"
85
85
  }
86
86
  }
@@ -90,9 +90,9 @@ enum InertStyleDiagnostic {
90
90
  case .gridWrap, .gridTile:
91
91
  "Declare `display: 'grid'` on the section or the page, or drop `\(rawValue)`."
92
92
  case .imageShape:
93
- "Render the items as plain or condensed grid tiles, or drop `imageShape`."
93
+ "Use `gridTile: 'image'` or `'condensed'` in a grid, or drop `imageShape`."
94
94
  case .accessorySymbol:
95
- "Use `gridTile: 'plain'` or `'condensed'`, or drop `accessorySymbol`."
95
+ "Use `gridTile: 'image'` or `'condensed'`, or a list, or drop `accessorySymbol`."
96
96
  case .cardTint, .cardImage:
97
97
  "Declare `display: 'grid'` with `gridTile: 'card'`, or drop `\(rawValue)`."
98
98
  }
@@ -129,8 +129,25 @@ enum InertStyleDiagnostic {
129
129
  }
130
130
 
131
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 }
132
+ /// condition the `card*` family is named after. Exhaustive, like
133
+ /// `isShaped`: a new tile kind has to be ruled on here.
134
+ var isCard: Bool {
135
+ guard isGrid else { return false }
136
+ return switch tile {
137
+ case .card: true
138
+ case .plain, .image, .condensed: false
139
+ }
140
+ }
141
+
142
+ /// Whether this container renders a tile family with a shape and an
143
+ /// accessory slot.
144
+ var isShaped: Bool {
145
+ guard isGrid else { return false }
146
+ return switch tile {
147
+ case .image, .condensed: true
148
+ case .plain, .card: false
149
+ }
150
+ }
134
151
 
135
152
  /// The effective style, for the message — in the wire spelling the author
136
153
  /// types (`stringValue` is the generated enum's own mapping). `gridTile`
@@ -41,11 +41,23 @@ enum SectionPresentation: Equatable {
41
41
  return symbol
42
42
  }
43
43
 
44
+ /// The title a tile shelf draws as the list section's header; nil when
45
+ /// the image row's own text carries it — here, not in the CarPlay factory,
46
+ /// so the rule is testable off-device. CarPlay draws a chevron beside an
47
+ /// image row's text whether or not the row has a handler, so only a shelf
48
+ /// with a "view all" `path` keeps its title there.
49
+ ///
50
+ /// - Parameter supportsElements: whether the platform has the iOS 26
51
+ /// element API; the legacy image row's text is not optional.
52
+ static func tileSectionHeader(_ section: Section, supportsElements: Bool) -> String? {
53
+ supportsElements && section.path == nil ? section.title : nil
54
+ }
55
+
44
56
  /// The element family a tile presentation renders on iOS 26+ — here, not
45
57
  /// in the CarPlay factory, so the style→family mapping is testable
46
- /// off-device. `gridTile` picks the family (plain by default); the plain
47
- /// single-line shelf keeps row elements, the one plain element with a
48
- /// subtitle slot. Pre-26 has no element API at all: the treatment drops
58
+ /// off-device. `gridTile` picks the family (plain by default), in either
59
+ /// wrap mode: a plain tile is a row element (title + subtitle), an image
60
+ /// tile an image-grid element (title, shape, accessory). Pre-26 has no element API at all: the treatment drops
49
61
  /// with the legacy image row (decorations drop before layout).
50
62
  enum TileElementFamily: Equatable {
51
63
  case rowElements
@@ -54,11 +66,12 @@ enum SectionPresentation: Equatable {
54
66
  case condensedElements
55
67
  }
56
68
 
57
- static func tileFamily(for style: SectionStyle?, singleLine: Bool) -> TileElementFamily {
69
+ static func tileFamily(for style: SectionStyle?) -> TileElementFamily {
58
70
  switch style?.gridTile {
71
+ case .image: .imageGridElements
59
72
  case .card: .cardElements
60
73
  case .condensed: .condensedElements
61
- case .plain, nil: singleLine ? .rowElements : .imageGridElements
74
+ case .plain, nil: .rowElements
62
75
  }
63
76
  }
64
77
  }
@@ -46,22 +46,29 @@ final class CarPlayListItemFactory {
46
46
 
47
47
  // MARK: - Sections
48
48
 
49
- /// Whether this OS has CarPlay's wrapping tile container (the iOS 26
50
- /// element API) — the capability `SectionPresentation` degrades against.
51
- private static var supportsWrappingGrid: Bool {
49
+ /// Whether this OS has CarPlay's iOS 26 element API — the wrapping tile
50
+ /// container and the text-less image row `SectionPresentation` degrades
51
+ /// against.
52
+ private static var supportsElements: Bool {
52
53
  if #available(iOS 26.0, *) { return true }
53
54
  return false
54
55
  }
55
56
 
57
+ private static func listSection(_ items: [CPListTemplateItem], header: String?) -> CPListSection {
58
+ if let header {
59
+ return CPListSection(items: items, header: header, sectionIndexTitle: nil)
60
+ }
61
+ return CPListSection(items: items)
62
+ }
63
+
56
64
  /// Maps the page's sections 1:1 to CPListSections (ADR 0010), respecting
57
65
  /// CarPlay's section and total-item budgets. Style blocks declare the
58
66
  /// requested presentation (resolved `section ?? page` here — ADR 0011);
59
67
  /// this renders CarPlay's nearest supported form (`SectionPresentation`):
60
68
  /// - list (default): a titled/headerless section of list rows.
61
- /// - single-line grid (`gridWrap: false`): a headerless section holding
62
- /// one single-line image-row item whose text is the section title — the
63
- /// tiles that fit render, the rest truncate (the platform doesn't
64
- /// report the fit).
69
+ /// - single-line grid (`gridWrap: false`): a section holding one
70
+ /// single-line image-row item — the tiles that fit render, the rest
71
+ /// truncate (the platform doesn't report the fit).
65
72
  /// - wrapping grid: a wrapping, titled tile grid on iOS 26+; a plain list
66
73
  /// before that.
67
74
  func createSections(from resolvedTrack: ResolvedTrack) -> [CPListSection] {
@@ -84,26 +91,24 @@ final class CarPlayListItemFactory {
84
91
  guard !section.children.isEmpty else { continue }
85
92
 
86
93
  let style = StyleResolver.sectionStyle(section: section.style, page: resolvedTrack.style)
87
- let presentation = SectionPresentation(for: style, supportsWrappingGrid: Self.supportsWrappingGrid)
94
+ let presentation = SectionPresentation(for: style, supportsWrappingGrid: Self.supportsElements)
88
95
  switch presentation {
89
96
  case .list:
90
97
  let availableSlots = maxTotalItems - totalItemCount
91
98
  let items: [CPListTemplateItem] = section.children.prefix(availableSlots).map {
92
99
  createListItem(for: $0, style: StyleResolver.trackStyle(track: $0.style, section: style))
93
100
  }
94
- if let title = section.title {
95
- listSections.append(CPListSection(items: items, header: title, sectionIndexTitle: nil))
96
- } else {
97
- listSections.append(CPListSection(items: items))
98
- }
101
+ listSections.append(Self.listSection(items, header: section.title))
99
102
  totalItemCount += items.count
100
103
  case .singleLineRow, .wrappingGrid:
101
- // Tile presentations render as one image-row item inside a
102
- // headerless CPListSection, with the section title as the item's
103
- // text — a headed section would render the title twice.
104
- guard let item = createImageRowItem(for: section, style: style, presentation: presentation)
105
- else { continue }
106
- listSections.append(CPListSection(items: [item]))
104
+ // Tile presentations render as one image-row item in its own
105
+ // CPListSection. The title is drawn once: as the section's header
106
+ // (`SectionPresentation.tileSectionHeader`), or as the item's text.
107
+ let header = SectionPresentation.tileSectionHeader(section, supportsElements: Self.supportsElements)
108
+ guard let item = createImageRowItem(
109
+ for: section, text: header == nil ? section.title : nil, style: style, presentation: presentation,
110
+ ) else { continue }
111
+ listSections.append(Self.listSection([item], header: header))
107
112
  totalItemCount += 1
108
113
  }
109
114
  }
@@ -213,16 +218,25 @@ final class CarPlayListItemFactory {
213
218
  return CPListImageRowItem.maximumImageSize
214
219
  }
215
220
 
216
- /// Target artwork size for one tile. Cards read their own class maximums
217
- /// ('background' selects the larger full-height target); every other
218
- /// family uses the shared element size.
219
- private static func tileImageSize(gridTile: GridTile?, cardImage: CardImage?) -> CGSize {
220
- if #available(iOS 26.0, *), gridTile == .card {
221
+ /// Target artwork size for one tile. Cards, image tiles and plain row
222
+ /// elements read their own class maximums ('background' selects the larger
223
+ /// full-height card target); condensed tiles use the shared element size.
224
+ private static func tileImageSize(
225
+ family: SectionPresentation.TileElementFamily, cardImage: CardImage?,
226
+ ) -> CGSize {
227
+ guard #available(iOS 26.0, *) else { return CPListImageRowItem.maximumImageSize }
228
+ switch family {
229
+ case .cardElements:
221
230
  return cardImage == .background
222
231
  ? CPListImageRowItemCardElement.maximumFullHeightImageSize
223
232
  : CPListImageRowItemCardElement.maximumImageSize
233
+ case .rowElements:
234
+ return CPListImageRowItemRowElement.maximumImageSize
235
+ case .imageGridElements:
236
+ return CPListImageRowItemImageGridElement.maximumImageSize
237
+ case .condensedElements:
238
+ return CPListImageRowItemElement.maximumImageSize
224
239
  }
225
- return rowImageSize
226
240
  }
227
241
 
228
242
  /// Creates the image-row item rendering a tile-presented section: a single
@@ -231,10 +245,14 @@ final class CarPlayListItemFactory {
231
245
  ///
232
246
  /// Returns nil when nothing is drawable — every child hidden by the
233
247
  /// disabled ladder (pre-26 only; the element APIs gray instead).
248
+ ///
249
+ /// - Parameter text: the row's own title; nil when the list section's
250
+ /// header carries it.
234
251
  private func createImageRowItem(
235
- for section: Section, style: SectionStyle, presentation: SectionPresentation,
252
+ for section: Section, text rowText: String?, style: SectionStyle, presentation: SectionPresentation,
236
253
  ) -> CPListImageRowItem? {
237
254
  let singleLine = presentation == .singleLineRow
255
+ let family = SectionPresentation.tileFamily(for: style)
238
256
  // Disabled ladder: iOS 26 elements can draw unavailability (grayed +
239
257
  // inert, below); the legacy image row can't, so there a disabled track
240
258
  // hides — never a normal-looking dead tile.
@@ -286,14 +304,14 @@ final class CarPlayListItemFactory {
286
304
  elements[index].image = image
287
305
  }
288
306
  }
289
- // The family is style-driven (`SectionPresentation.tileFamily`); both
290
- // non-plain families take either wrap mode. Pre-26 the treatment drops
291
- // and the layout survives (the legacy branch below).
292
- switch SectionPresentation.tileFamily(for: style, singleLine: singleLine) {
307
+ // The family is style-driven (`SectionPresentation.tileFamily`); every
308
+ // family takes either wrap mode. Pre-26 the treatment drops and the
309
+ // layout survives (the legacy branch below).
310
+ switch family {
293
311
  case .cardElements:
294
312
  // Cards have no shape and no accessory slot; their knobs are the
295
313
  // tint and the image mode ('background' fills the card full-height
296
- // and turns the tint into the color behind the labels).
314
+ // and fades the tint in behind the labels).
297
315
  let elements = makeElements { track, resolved in
298
316
  CPListImageRowItemCardElement(
299
317
  image: placeholder(),
@@ -304,7 +322,7 @@ final class CarPlayListItemFactory {
304
322
  )
305
323
  }
306
324
  item = CPListImageRowItem(
307
- text: section.title, cardElements: elements, allowsMultipleLines: !singleLine,
325
+ text: rowText, cardElements: elements, allowsMultipleLines: !singleLine,
308
326
  )
309
327
  applyImage = imageApplier(elements)
310
328
  case .condensedElements:
@@ -318,19 +336,9 @@ final class CarPlayListItemFactory {
318
336
  )
319
337
  }
320
338
  item = CPListImageRowItem(
321
- text: section.title, condensedElements: elements, allowsMultipleLines: !singleLine,
339
+ text: rowText, condensedElements: elements, allowsMultipleLines: !singleLine,
322
340
  )
323
341
  applyImage = imageApplier(elements)
324
- case .rowElements:
325
- // The only plain tile with a subtitle slot — the imageGridElements
326
- // family has no equivalent, by SDK design.
327
- let elements = makeElements { track, _ in
328
- CPListImageRowItemRowElement(
329
- image: placeholder(), title: track.title, subtitle: track.subtitle,
330
- )
331
- }
332
- item = CPListImageRowItem(text: section.title, elements: elements, allowsMultipleLines: false)
333
- applyImage = imageApplier(elements)
334
342
  case .imageGridElements:
335
343
  // imageGridElements, never the title-less gridElements: an
336
344
  // artwork-less track must keep its name on screen (ADR 0010).
@@ -342,28 +350,40 @@ final class CarPlayListItemFactory {
342
350
  accessorySymbolName: SectionPresentation.effectiveAccessorySymbol(resolved),
343
351
  )
344
352
  }
345
- item = CPListImageRowItem(text: section.title, imageGridElements: elements, allowsMultipleLines: true)
353
+ item = CPListImageRowItem(
354
+ text: rowText, imageGridElements: elements, allowsMultipleLines: !singleLine,
355
+ )
356
+ applyImage = imageApplier(elements)
357
+ case .rowElements:
358
+ // Titled, so an artwork-less track keeps its name on screen
359
+ // (ADR 0010); no shape and no accessory slot.
360
+ let elements = makeElements { track, _ in
361
+ CPListImageRowItemRowElement(
362
+ image: placeholder(), title: track.title, subtitle: track.subtitle,
363
+ )
364
+ }
365
+ item = CPListImageRowItem(
366
+ text: rowText, elements: elements, allowsMultipleLines: !singleLine,
367
+ )
346
368
  applyImage = imageApplier(elements)
347
369
  }
348
370
  } else {
349
371
  let placeholders = tracks.map { _ in placeholder() }
350
372
  // Use imageTitles variant on iOS 17.4+ to show titles below each thumbnail
351
373
  if #available(iOS 17.4, *) {
352
- item = CPListImageRowItem(text: section.title ?? "", images: placeholders, imageTitles: tracks.map(\.title))
374
+ item = CPListImageRowItem(text: rowText ?? "", images: placeholders, imageTitles: tracks.map(\.title))
353
375
  } else {
354
- item = CPListImageRowItem(text: section.title ?? "", images: placeholders)
376
+ item = CPListImageRowItem(text: rowText ?? "", images: placeholders)
355
377
  }
356
378
  }
357
379
 
358
380
  // Header tap → navigate to section.path ("view all"). A path-less
359
- // section is a pure preview — its header tap is a no-op rather than a
360
- // selection that can't resolve.
361
- item.handler = { [onItemSelected] _, completion in
362
- guard let path = section.path else {
363
- completion()
364
- return
381
+ // section is a pure preview and gets no handler.
382
+ if let path = section.path {
383
+ let target = Self.navigationTrack(path: path, title: section.title)
384
+ item.handler = { [onItemSelected] _, completion in
385
+ onItemSelected(target, completion)
365
386
  }
366
- onItemSelected(Self.navigationTrack(path: path, title: section.title), completion)
367
387
  }
368
388
 
369
389
  // Per-tile taps select the child track directly. A disabled tile is
@@ -385,7 +405,7 @@ final class CarPlayListItemFactory {
385
405
  imageLoader?.loadArtwork(
386
406
  for: track,
387
407
  style: resolved,
388
- size: Self.tileImageSize(gridTile: style.gridTile, cardImage: resolved.cardImage),
408
+ size: Self.tileImageSize(family: family, cardImage: resolved.cardImage),
389
409
  ) { [weak item, applyImage] image in
390
410
  Task { @MainActor in
391
411
  guard let item, let image else { return }
@@ -128,12 +128,14 @@
128
128
 
129
129
  enum GridTile: Equatable {
130
130
  case plain
131
+ case image
131
132
  case card
132
133
  case condensed
133
134
 
134
135
  init?(fromString string: String) {
135
136
  switch string {
136
137
  case "plain": self = .plain
138
+ case "image": self = .image
137
139
  case "card": self = .card
138
140
  case "condensed": self = .condensed
139
141
  default: return nil
@@ -145,6 +147,7 @@
145
147
  var stringValue: String {
146
148
  switch self {
147
149
  case .plain: "plain"
150
+ case .image: "image"
148
151
  case .card: "card"
149
152
  case .condensed: "condensed"
150
153
  }
@@ -74,22 +74,31 @@ struct InertStyleDiagnosticTests {
74
74
 
75
75
  @Test func imageShapeNeedsAShapedTile() {
76
76
  let list = SectionStyle(imageShape: .circular)
77
+ let plain = SectionStyle(display: .grid, imageShape: .circular)
77
78
  let cards = SectionStyle(gridTile: .card, display: .grid, imageShape: .circular)
78
79
  let condensed = SectionStyle(gridTile: .condensed, display: .grid, imageShape: .circular)
80
+ let image = SectionStyle(gridTile: .image, display: .grid, imageShape: .circular)
79
81
  #expect(findings([Section(style: list, children: [])]).count == 1)
82
+ #expect(findings([Section(style: plain, children: [])]).count == 1)
80
83
  #expect(findings([Section(style: cards, children: [])]).count == 1)
81
- // Plain and condensed tiles both take a shape.
82
84
  #expect(findings([Section(style: condensed, children: [])]).isEmpty)
85
+ #expect(findings([Section(style: image, children: [])]).isEmpty)
83
86
  }
84
87
 
85
- @Test func accessorySymbolIsInertOnlyOnCards() {
88
+ @Test func accessorySymbolNeedsAnAccessorySlot() {
86
89
  let rows = SectionStyle(accessorySymbol: "lock.fill")
90
+ let condensed = SectionStyle(gridTile: .condensed, display: .grid, accessorySymbol: "lock.fill")
91
+ let image = SectionStyle(gridTile: .image, display: .grid, accessorySymbol: "lock.fill")
92
+ let plain = SectionStyle(display: .grid, accessorySymbol: "lock.fill")
87
93
  let cards = SectionStyle(gridTile: .card, display: .grid, accessorySymbol: "lock.fill")
88
- // List rows draw accessories; card elements have no slot for one.
89
94
  #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"))
95
+ #expect(findings([Section(style: condensed, children: [])]).isEmpty)
96
+ #expect(findings([Section(style: image, children: [])]).isEmpty)
97
+ for style in [plain, cards] {
98
+ let found = findings([Section(style: style, children: [])])
99
+ #expect(found.count == 1)
100
+ #expect(found[0].contains("accessory slot"))
101
+ }
93
102
  }
94
103
 
95
104
  @Test func artworkRenderingIsNeverInert() {
@@ -114,9 +123,9 @@ struct InertStyleDiagnosticTests {
114
123
  #expect(found[0].contains("style.cardTint"))
115
124
  }
116
125
 
117
- @Test func aTracksOwnShapeIsLiveInAPlainGrid() {
126
+ @Test func aTracksOwnShapeIsLiveInAShapedGrid() {
118
127
  let section = Section(
119
- style: SectionStyle(display: .grid),
128
+ style: SectionStyle(gridTile: .image, display: .grid),
120
129
  children: [playable(style: TrackStyle(imageShape: .circular))],
121
130
  )
122
131
  #expect(findings([section]).isEmpty)
@@ -162,6 +162,20 @@ struct JsonStyleTests {
162
162
  #expect(style?.cardImage == .background)
163
163
  }
164
164
 
165
+ @Test func decodesTheImageFamily() throws {
166
+ let json = Data(
167
+ """
168
+ {"path":"/home","title":"Home","sections":[
169
+ {"style":{"display":"grid","gridTile":"image","imageShape":"circular"},
170
+ "children":[{"title":"C","src":"s"}]}
171
+ ]}
172
+ """.utf8)
173
+ let resolved = try JSONDecoder().decode(JsonResolvedTrack.self, from: json).toNitro()
174
+ let style = resolved.sections?.first?.style
175
+ #expect(style?.gridTile == .image)
176
+ #expect(style?.imageShape == .circular)
177
+ }
178
+
165
179
  @Test func unknownGridTileDecodesAsNoDeclaration() throws {
166
180
  let json = Data(
167
181
  """
@@ -59,21 +59,29 @@ struct SectionPresentationTests {
59
59
  == .list)
60
60
  }
61
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.
62
+ @Test func tileFamilyFollowsGridTile() {
63
+ // The family follows the declaration alone; the wrap mode only decides
64
+ // whether its elements take more than one line.
65
+ #expect(SectionPresentation.tileFamily(for: SectionStyle(gridTile: .card)) == .cardElements)
66
66
  #expect(
67
- SectionPresentation.tileFamily(for: SectionStyle(gridTile: .card), singleLine: true)
68
- == .cardElements)
67
+ SectionPresentation.tileFamily(for: SectionStyle(gridTile: .image)) == .imageGridElements)
69
68
  #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)
69
+ SectionPresentation.tileFamily(for: SectionStyle(gridTile: .condensed)) == .condensedElements)
70
+ #expect(SectionPresentation.tileFamily(for: nil) == .rowElements)
71
+ #expect(SectionPresentation.tileFamily(for: SectionStyle(gridTile: .plain)) == .rowElements)
72
+ }
73
+
74
+ @Test func aPathlessShelfTitlesTheSectionHeader() {
75
+ let viewAll = Section(title: "Popular", path: "/popular", children: [])
76
+ let preview = Section(title: "Popular", children: [])
77
+ let untitled = Section(children: [])
78
+ // The image row's own text carries a chevron, so it is kept for the
79
+ // shelf that has somewhere to go.
80
+ #expect(SectionPresentation.tileSectionHeader(viewAll, supportsElements: true) == nil)
81
+ #expect(SectionPresentation.tileSectionHeader(preview, supportsElements: true) == "Popular")
82
+ #expect(SectionPresentation.tileSectionHeader(untitled, supportsElements: true) == nil)
83
+ // The legacy row cannot drop its text.
84
+ #expect(SectionPresentation.tileSectionHeader(preview, supportsElements: false) == nil)
77
85
  }
78
86
 
79
87
  @Test func effectiveAccessorySymbol_noneAndAbsenceDrawNothing() {
@@ -48,6 +48,11 @@ struct SectionStyleResolutionTests {
48
48
  page: SectionStyle(gridTile: .card),
49
49
  )
50
50
  #expect(overridden.gridTile == .plain)
51
+ let image = StyleResolver.sectionStyle(
52
+ section: SectionStyle(gridTile: .image),
53
+ page: SectionStyle(gridTile: .card),
54
+ )
55
+ #expect(image.gridTile == .image)
51
56
  }
52
57
  }
53
58
 
@@ -23,7 +23,7 @@ export type StyleDisplay = 'list' | 'grid';
23
23
  */
24
24
  export type ArtworkRendering = 'original' | 'stencil';
25
25
  /**
26
- * The shape a grid tile's artwork is cropped to.
26
+ * The shape an image or condensed grid tile's artwork is cropped to.
27
27
  *
28
28
  * - `'rounded-rectangle'` (default) — the standard tile crop.
29
29
  * - `'circular'` — the standard presentation for *people* (artists, hosts,
@@ -38,26 +38,31 @@ export type ImageShape = 'circular' | 'rounded-rectangle';
38
38
  /**
39
39
  * The kind of tile a grid renders.
40
40
  *
41
- * - `'plain'` (default) — artwork tiles with a title.
41
+ * - `'plain'` (default) — large artwork tiles with a title and subtitle
42
+ * below. No shape and no accessory.
43
+ * - `'image'` — artwork tiles with a title and no subtitle; takes
44
+ * {@link TrackStyle.imageShape} and {@link TrackStyle.accessorySymbol}.
45
+ * The grid of round tiles for people is `'image'` with
46
+ * `imageShape: 'circular'`.
42
47
  * - `'card'` — a larger card with title/subtitle and a
43
48
  * {@link TrackStyle.cardTint} color; its image mode is
44
- * {@link TrackStyle.cardImage}. Cards have no shape.
45
- * - `'condensed'` — a denser cell carrying title + subtitle; takes
46
- * {@link TrackStyle.imageShape} and {@link TrackStyle.accessorySymbol}
47
- * like a plain tile.
49
+ * {@link TrackStyle.cardImage}. Cards have no shape and no accessory.
50
+ * - `'condensed'` — a denser cell carrying title + subtitle beside a small
51
+ * image; takes {@link TrackStyle.imageShape} and
52
+ * {@link TrackStyle.accessorySymbol}.
48
53
  *
49
54
  * Element families are mutually exclusive within a container, so this is
50
55
  * a container property — hence the enum, and the `grid` prefix.
51
56
  */
52
- export type GridTile = 'plain' | 'card' | 'condensed';
57
+ export type GridTile = 'plain' | 'image' | 'card' | 'condensed';
53
58
  /**
54
59
  * How a card tile uses its image.
55
60
  *
56
- * - `'normal'` (default) — image above the labels;
57
- * {@link TrackStyle.cardTint} colors a gradient behind the labels.
61
+ * - `'normal'` (default) — a square image inset above the labels;
62
+ * {@link TrackStyle.cardTint} colors the whole card.
58
63
  * - `'background'` — the image fills the card full-height with the labels
59
- * overlaid; `cardTint` becomes the color behind them. Selects the larger
60
- * artwork target size.
64
+ * overlaid; `cardTint` fades in behind them. Selects the larger artwork
65
+ * target size.
61
66
  *
62
67
  * An enum rather than a boolean: it names the card's image mode and
63
68
  * leaves room for future modes.
@@ -120,12 +125,13 @@ export interface TrackStyle {
120
125
  */
121
126
  artworkRendering?: ArtworkRendering;
122
127
  /**
123
- * Inherited (`track ?? section ?? page`): the shape a grid tile's
124
- * artwork is cropped to — `'circular'` for people, the default
125
- * `'rounded-rectangle'` for everything else. Shape follows the item
126
- * wherever it travels: a circular artist stays circular in whatever
127
- * grid renders it next. Inert where the presentation has no shape
128
- * (list rows, pre-26 CarPlay, Android Auto).
128
+ * Inherited (`track ?? section ?? page`): the shape an image or
129
+ * condensed grid tile's artwork is cropped to — `'circular'` for people,
130
+ * the default `'rounded-rectangle'` for everything else. Shape follows
131
+ * the item wherever it travels: a circular artist stays circular in
132
+ * whatever shaped grid renders it next. Inert where the presentation has
133
+ * no shape (list rows, plain and card tiles, pre-26 CarPlay, Android
134
+ * Auto).
129
135
  *
130
136
  * @default 'rounded-rectangle'
131
137
  *
@@ -135,8 +141,9 @@ export interface TrackStyle {
135
141
  /**
136
142
  * Inherited (`track ?? section ?? page`): an SF Symbol name drawn as
137
143
  * the item's accessory wherever the current presentation supports one —
138
- * the trailing edge of a list row, leading the title on an iOS 26 grid
139
- * tile (placement is the platform's choice per surface). A resolved
144
+ * the trailing edge of a list row or an iOS 26 condensed tile, leading
145
+ * the title on an image tile (placement is the platform's choice per
146
+ * surface); plain and card tiles have no slot for one. A resolved
140
147
  * symbol replaces the derived accessory (the disclosure chevron a
141
148
  * browsable row gets); `'none'` clears an inherited value, restoring
142
149
  * the derived behavior — inheritance has no other escape, and no SF
@@ -148,8 +155,8 @@ export interface TrackStyle {
148
155
  accessorySymbol?: string;
149
156
  /**
150
157
  * Inherited (`track ?? section ?? page`): the card treatment's tint
151
- * color (hex, e.g. `'#1e3a8a'`) — a gradient behind the labels, or the
152
- * color behind them when {@link TrackStyle.cardImage} is
158
+ * color (hex, e.g. `'#1e3a8a'`) — the card's own color, or the color
159
+ * that fades in behind the labels when {@link TrackStyle.cardImage} is
153
160
  * `'background'`. Card-prefixed: inert unless the container renders
154
161
  * `gridTile: 'card'`.
155
162
  *
@@ -1 +1 @@
1
- {"version":3,"file":"browser-nodes.d.ts","sourceRoot":"","sources":["../../../../src/types/browser-nodes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"browser-nodes.d.ts","sourceRoot":"","sources":["../../../../src/types/browser-nodes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,6BAA6B,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AAE1E;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,CAAA;AAE1C;;;;;;;;GAQG;AACH,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,SAAS,CAAA;AAErD;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,mBAAmB,CAAA;AAEzD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,WAAW,CAAA;AAE/D;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,YAAY,CAAA;AAE/C;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,UAAU;IACzB;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,OAAO,CAAC,EAAE,YAAY,CAAA;IAEtB;;;;;;;;OAQG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IAEnC;;;;;;;;;;;;OAYG;IACH,UAAU,CAAC,EAAE,UAAU,CAAA;IAEvB;;;;;;;;;;;;;OAaG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IAExB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB;;;;;;;;;OASG;IACH,SAAS,CAAC,EAAE,SAAS,CAAA;CACtB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAElB;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAA;CACpB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,WAAW;IAC1B,+DAA+D;IAC/D,GAAG,EAAE,MAAM,CAAA;IACX,2CAA2C;IAC3C,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B,2BAA2B;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAA;CACb;AAOD,mDAAmD;AACnD,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,eAAe,CAAA;AAEnD;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,8CAA8C;IAC9C,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;IACpB;;;;;;;;;;;;;;;OAeG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,4BAA4B;IAC5B,QAAQ,EAAE,KAAK,EAAE,CAAA;CAClB;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,KAAK;IACpB;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,EAAE,CAAC,EAAE,MAAM,CAAA;IAEX;;;;;;;;;OASG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IAEb;;;;;;;;OAQG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IAEZ;;;;;;;;;;;;;;;;;;;OAmBG;IACH,OAAO,CAAC,EAAE,YAAY,CAAA;IAEtB;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,WAAW,CAAA;IAEpC,uFAAuF;IACvF,OAAO,CAAC,EAAE,YAAY,CAAA;IAGtB,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAA;IAEb;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IAEpB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IAEd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,UAAU,CAAA;IAElB;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAElB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IAEnB;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;IAEd;;;;;;;;;;;;OAYG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,aAAc,SAAQ,KAAK;IAC1C;;OAEG;IACH,IAAI,EAAE,MAAM,CAAA;IAEZ;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;IAEpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAA;IAEpB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,KAAK,EAAE,CAAA;IAElB;;;;;;;;;;;;OAYG;IACH,qBAAqB,CAAC,EAAE,6BAA6B,CAAA;CACtD"}
@@ -45,6 +45,9 @@ namespace margelo::nitro::audiobrowser {
45
45
  case GridTile::PLAIN:
46
46
  static const auto fieldPLAIN = clazz->getStaticField<JGridTile>("PLAIN");
47
47
  return clazz->getStaticFieldValue(fieldPLAIN);
48
+ case GridTile::IMAGE:
49
+ static const auto fieldIMAGE = clazz->getStaticField<JGridTile>("IMAGE");
50
+ return clazz->getStaticFieldValue(fieldIMAGE);
48
51
  case GridTile::CARD:
49
52
  static const auto fieldCARD = clazz->getStaticField<JGridTile>("CARD");
50
53
  return clazz->getStaticFieldValue(fieldCARD);
@@ -17,8 +17,9 @@ import com.facebook.proguard.annotations.DoNotStrip
17
17
  @Keep
18
18
  enum class GridTile(@DoNotStrip @Keep val value: Int) {
19
19
  PLAIN(0),
20
- CARD(1),
21
- CONDENSED(2);
20
+ IMAGE(1),
21
+ CARD(2),
22
+ CONDENSED(3);
22
23
 
23
24
  companion object
24
25
  }
@@ -19,6 +19,8 @@ public extension GridTile {
19
19
  switch string {
20
20
  case "plain":
21
21
  self = .plain
22
+ case "image":
23
+ self = .image
22
24
  case "card":
23
25
  self = .card
24
26
  case "condensed":
@@ -35,6 +37,8 @@ public extension GridTile {
35
37
  switch self {
36
38
  case .plain:
37
39
  return "plain"
40
+ case .image:
41
+ return "image"
38
42
  case .card:
39
43
  return "card"
40
44
  case .condensed:
@@ -30,8 +30,9 @@ namespace margelo::nitro::audiobrowser {
30
30
  */
31
31
  enum class GridTile {
32
32
  PLAIN SWIFT_NAME(plain) = 0,
33
- CARD SWIFT_NAME(card) = 1,
34
- CONDENSED SWIFT_NAME(condensed) = 2,
33
+ IMAGE SWIFT_NAME(image) = 1,
34
+ CARD SWIFT_NAME(card) = 2,
35
+ CONDENSED SWIFT_NAME(condensed) = 3,
35
36
  } CLOSED_ENUM;
36
37
 
37
38
  } // namespace margelo::nitro::audiobrowser
@@ -45,6 +46,7 @@ namespace margelo::nitro {
45
46
  std::string unionValue = JSIConverter<std::string>::fromJSI(runtime, arg);
46
47
  switch (hashString(unionValue.c_str(), unionValue.size())) {
47
48
  case hashString("plain"): return margelo::nitro::audiobrowser::GridTile::PLAIN;
49
+ case hashString("image"): return margelo::nitro::audiobrowser::GridTile::IMAGE;
48
50
  case hashString("card"): return margelo::nitro::audiobrowser::GridTile::CARD;
49
51
  case hashString("condensed"): return margelo::nitro::audiobrowser::GridTile::CONDENSED;
50
52
  default: [[unlikely]]
@@ -54,6 +56,7 @@ namespace margelo::nitro {
54
56
  static inline jsi::Value toJSI(jsi::Runtime& runtime, margelo::nitro::audiobrowser::GridTile arg) {
55
57
  switch (arg) {
56
58
  case margelo::nitro::audiobrowser::GridTile::PLAIN: return JSIConverter<std::string>::toJSI(runtime, "plain");
59
+ case margelo::nitro::audiobrowser::GridTile::IMAGE: return JSIConverter<std::string>::toJSI(runtime, "image");
57
60
  case margelo::nitro::audiobrowser::GridTile::CARD: return JSIConverter<std::string>::toJSI(runtime, "card");
58
61
  case margelo::nitro::audiobrowser::GridTile::CONDENSED: return JSIConverter<std::string>::toJSI(runtime, "condensed");
59
62
  default: [[unlikely]]
@@ -68,6 +71,7 @@ namespace margelo::nitro {
68
71
  std::string unionValue = JSIConverter<std::string>::fromJSI(runtime, value);
69
72
  switch (hashString(unionValue.c_str(), unionValue.size())) {
70
73
  case hashString("plain"):
74
+ case hashString("image"):
71
75
  case hashString("card"):
72
76
  case hashString("condensed"):
73
77
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-browser",
3
- "version": "0.2.0-next.20",
3
+ "version": "0.2.0-next.22",
4
4
  "description": "react-native-audio-browser is a react native package built with Nitro",
5
5
  "keywords": [
6
6
  "react-native",
@@ -26,7 +26,7 @@ export type StyleDisplay = 'list' | 'grid'
26
26
  export type ArtworkRendering = 'original' | 'stencil'
27
27
 
28
28
  /**
29
- * The shape a grid tile's artwork is cropped to.
29
+ * The shape an image or condensed grid tile's artwork is cropped to.
30
30
  *
31
31
  * - `'rounded-rectangle'` (default) — the standard tile crop.
32
32
  * - `'circular'` — the standard presentation for *people* (artists, hosts,
@@ -42,27 +42,32 @@ export type ImageShape = 'circular' | 'rounded-rectangle'
42
42
  /**
43
43
  * The kind of tile a grid renders.
44
44
  *
45
- * - `'plain'` (default) — artwork tiles with a title.
45
+ * - `'plain'` (default) — large artwork tiles with a title and subtitle
46
+ * below. No shape and no accessory.
47
+ * - `'image'` — artwork tiles with a title and no subtitle; takes
48
+ * {@link TrackStyle.imageShape} and {@link TrackStyle.accessorySymbol}.
49
+ * The grid of round tiles for people is `'image'` with
50
+ * `imageShape: 'circular'`.
46
51
  * - `'card'` — a larger card with title/subtitle and a
47
52
  * {@link TrackStyle.cardTint} color; its image mode is
48
- * {@link TrackStyle.cardImage}. Cards have no shape.
49
- * - `'condensed'` — a denser cell carrying title + subtitle; takes
50
- * {@link TrackStyle.imageShape} and {@link TrackStyle.accessorySymbol}
51
- * like a plain tile.
53
+ * {@link TrackStyle.cardImage}. Cards have no shape and no accessory.
54
+ * - `'condensed'` — a denser cell carrying title + subtitle beside a small
55
+ * image; takes {@link TrackStyle.imageShape} and
56
+ * {@link TrackStyle.accessorySymbol}.
52
57
  *
53
58
  * Element families are mutually exclusive within a container, so this is
54
59
  * a container property — hence the enum, and the `grid` prefix.
55
60
  */
56
- export type GridTile = 'plain' | 'card' | 'condensed'
61
+ export type GridTile = 'plain' | 'image' | 'card' | 'condensed'
57
62
 
58
63
  /**
59
64
  * How a card tile uses its image.
60
65
  *
61
- * - `'normal'` (default) — image above the labels;
62
- * {@link TrackStyle.cardTint} colors a gradient behind the labels.
66
+ * - `'normal'` (default) — a square image inset above the labels;
67
+ * {@link TrackStyle.cardTint} colors the whole card.
63
68
  * - `'background'` — the image fills the card full-height with the labels
64
- * overlaid; `cardTint` becomes the color behind them. Selects the larger
65
- * artwork target size.
69
+ * overlaid; `cardTint` fades in behind them. Selects the larger artwork
70
+ * target size.
66
71
  *
67
72
  * An enum rather than a boolean: it names the card's image mode and
68
73
  * leaves room for future modes.
@@ -128,12 +133,13 @@ export interface TrackStyle {
128
133
  artworkRendering?: ArtworkRendering
129
134
 
130
135
  /**
131
- * Inherited (`track ?? section ?? page`): the shape a grid tile's
132
- * artwork is cropped to — `'circular'` for people, the default
133
- * `'rounded-rectangle'` for everything else. Shape follows the item
134
- * wherever it travels: a circular artist stays circular in whatever
135
- * grid renders it next. Inert where the presentation has no shape
136
- * (list rows, pre-26 CarPlay, Android Auto).
136
+ * Inherited (`track ?? section ?? page`): the shape an image or
137
+ * condensed grid tile's artwork is cropped to — `'circular'` for people,
138
+ * the default `'rounded-rectangle'` for everything else. Shape follows
139
+ * the item wherever it travels: a circular artist stays circular in
140
+ * whatever shaped grid renders it next. Inert where the presentation has
141
+ * no shape (list rows, plain and card tiles, pre-26 CarPlay, Android
142
+ * Auto).
137
143
  *
138
144
  * @default 'rounded-rectangle'
139
145
  *
@@ -144,8 +150,9 @@ export interface TrackStyle {
144
150
  /**
145
151
  * Inherited (`track ?? section ?? page`): an SF Symbol name drawn as
146
152
  * the item's accessory wherever the current presentation supports one —
147
- * the trailing edge of a list row, leading the title on an iOS 26 grid
148
- * tile (placement is the platform's choice per surface). A resolved
153
+ * the trailing edge of a list row or an iOS 26 condensed tile, leading
154
+ * the title on an image tile (placement is the platform's choice per
155
+ * surface); plain and card tiles have no slot for one. A resolved
149
156
  * symbol replaces the derived accessory (the disclosure chevron a
150
157
  * browsable row gets); `'none'` clears an inherited value, restoring
151
158
  * the derived behavior — inheritance has no other escape, and no SF
@@ -158,8 +165,8 @@ export interface TrackStyle {
158
165
 
159
166
  /**
160
167
  * Inherited (`track ?? section ?? page`): the card treatment's tint
161
- * color (hex, e.g. `'#1e3a8a'`) — a gradient behind the labels, or the
162
- * color behind them when {@link TrackStyle.cardImage} is
168
+ * color (hex, e.g. `'#1e3a8a'`) — the card's own color, or the color
169
+ * that fades in behind the labels when {@link TrackStyle.cardImage} is
163
170
  * `'background'`. Card-prefixed: inert unless the container renders
164
171
  * `gridTile: 'card'`.
165
172
  *
@@ -238,7 +238,7 @@ A page's [`Section`](/api/types/browser-nodes/#section) can render its tracks as
238
238
  }
239
239
  ```
240
240
 
241
- Each tile is an ordinary Track, so its `artwork` runs through the same pipeline as any other. Tile styles presume artwork: a track without any renders as a placeholder tile plus its title — the artwork `resolve` hook is the place to supply fallback art. On CarPlay a single-line grid (`gridWrap: false`) shows the tiles that fit (roughly eight, width-dependent; the rest are truncated) and a wrapping grid takes as many lines as it needs on iOS 26+ (rendering as a list before that); Android Auto's grid always wraps, so it renders both identically. See [Browser → Presentation](/guide/browser#presentation) for the full per-surface rundown.
241
+ Each tile is an ordinary Track, so its `artwork` runs through the same pipeline as any other. Tile styles presume artwork: a track without any renders as a placeholder tile plus its title — the artwork `resolve` hook is the place to supply fallback art. On CarPlay a single-line grid (`gridWrap: false`) shows the tiles that fit on one line (seven on a wide screen, or two condensed cells; the rest are truncated) and a wrapping grid takes as many lines as it needs on iOS 26+ (rendering as a list before that); Android Auto's grid always wraps, so it renders both identically. See [Browser → Presentation](/guide/browser#presentation) for the full per-surface rundown.
242
242
 
243
243
  ## API summary
244
244
 
@@ -521,9 +521,9 @@ Presentation lives in `style` blocks, separate from content, plus a few Track fi
521
521
  | `style` (same block) | a page | declaration for the whole page — a section overrides it for its own children | all |
522
522
  | `style: { display: 'grid' }` | a browsable track | the layout of the page it opens — Android Auto reads it from the parent item | Android Auto / AAOS |
523
523
  | `style: { artworkRendering: 'stencil' }` | a track / section / page | tint monochrome artwork to the surface appearance | CarPlay |
524
- | `style: { imageShape: 'circular' }` | a track / section / page | crop grid-tile artwork circular (people) instead of rounded | CarPlay (iOS 26+) |
525
- | `style: { accessorySymbol: 'lock.fill' }` | a track / section / page | SF Symbol accessory on rows and tiles (`'none'` restores the derived one) | CarPlay |
526
- | `style: { gridTile: 'card' }` | a section / page | the grid's tile kind: `'plain'` (default), `'card'`, or `'condensed'` | CarPlay (iOS 26+) |
524
+ | `style: { imageShape: 'circular' }` | a track / section / page | crop `'image'` and `'condensed'` tile artwork circular (people) | CarPlay (iOS 26+) |
525
+ | `style: { accessorySymbol: 'lock.fill' }` | a track / section / page | SF Symbol accessory on rows and on `'image'` / `'condensed'` tiles | CarPlay |
526
+ | `style: { gridTile: 'card' }` | a section / page | the grid's tile kind: `'plain'` (default), `'image'`, `'card'`, or `'condensed'` | CarPlay (iOS 26+) |
527
527
  | `style: { cardTint: '#1e3a8a' }` (+ `cardImage`) | a track / section / page | card color and image mode (`'background'` fills the card full-height) | CarPlay (iOS 26+) |
528
528
  | `disabled: true` | a track | unavailable: never plays; grayed where drawable, hidden elsewhere | all |
529
529
  | `artwork: 'sf:heart.fill'` | any item | SF Symbol icon (supports `?bg=…&fg=…`) | iOS |
@@ -532,11 +532,11 @@ Presentation lives in `style` blocks, separate from content, plus a few Track fi
532
532
  | `albumPath` + `resolveAlbumPath` | track + config | make the now-playing album line tappable | CarPlay |
533
533
 
534
534
  ::: info Caveats from the table
535
- **`albumPath` requires `album`** — CarPlay renders the tappable line from the album metadata, so without an `album` there is no line to tap. **\*Layouts render each platform's nearest supported form** — a single-line grid (`gridWrap: false`) on CarPlay shows the tiles that fit (roughly eight, width-dependent) and truncates the rest; a wrapping grid takes as many lines as it needs on iOS 26+ and renders as a list before that. Android Auto's grid always wraps, so it shows more, never less. No car surface scrolls horizontally; an app UI typically renders `gridWrap: false` as a horizontal scroller. Truncation is visual only: tapping a tile queues the whole section (see [Playback behavior](#playback-behavior)). Item properties like `artworkRendering`, `imageShape`, and `accessorySymbol` are inherited — `track ?? section ?? page`, per property — so a section-wide value can be overridden per item; `accessorySymbol: 'none'` is the per-item escape from an inherited symbol, and where the accessory draws is the platform's choice per surface (trailing on list rows, leading the title on tiles). `display` is never inherited: it always describes the children of whatever it's set on. On a page or section it lays out that page or section; on a browsable track it declares the layout of the page that track opens — Android Auto needs it there, or that page renders as a list. `gridWrap` is inert unless `display` is `'grid'`. One AAOS caveat: AOSP-based Automotive units honor only the parent item's `display`, so a page mixing grid and list sections renders uniformly there — see [Android Auto → Browse display](/guide/android-auto#browse-display).
535
+ **`albumPath` requires `album`** — CarPlay renders the tappable line from the album metadata, so without an `album` there is no line to tap. **\*Layouts render each platform's nearest supported form** — a single-line grid (`gridWrap: false`) on CarPlay shows the tiles that fit on one line (seven on a wide screen, or two condensed cells) and truncates the rest; a wrapping grid takes as many lines as it needs on iOS 26+ and renders as a list before that. Android Auto's grid always wraps, so it shows more, never less. No car surface scrolls horizontally; an app UI typically renders `gridWrap: false` as a horizontal scroller. Truncation is visual only: tapping a tile queues the whole section (see [Playback behavior](#playback-behavior)). Item properties like `artworkRendering`, `imageShape`, and `accessorySymbol` are inherited — `track ?? section ?? page`, per property — so a section-wide value can be overridden per item; `accessorySymbol: 'none'` is the per-item escape from an inherited symbol, and where the accessory draws is the platform's choice per surface (trailing on list rows and condensed tiles, leading the title on image tiles). On an iOS 26 grid, which tile draws what is `gridTile`'s choice: `'plain'` has a subtitle but no shape and no accessory, `'image'` has a shape and an accessory but no subtitle, `'condensed'` has all three beside a small image, and `'card'` has a subtitle, a tint and an image mode. `display` is never inherited: it always describes the children of whatever it's set on. On a page or section it lays out that page or section; on a browsable track it declares the layout of the page that track opens — Android Auto needs it there, or that page renders as a list. `gridWrap` is inert unless `display` is `'grid'`. One AAOS caveat: AOSP-based Automotive units honor only the parent item's `display`, so a page mixing grid and list sections renders uniformly there — see [Android Auto → Browse display](/guide/android-auto#browse-display).
536
536
  :::
537
537
 
538
538
  ::: tip Inert declarations are logged in debug builds
539
- A combination that can never render — `gridWrap` or `gridTile` outside a grid, `cardTint` with no card treatment in scope, `imageShape` on a list or a card, `accessorySymbol` on a card, `display` on a track that renders playable — is inert, not an error, so nothing breaks. In a **debug build**, each one is logged once per page resolution (`Timber` on Android, `os.log` on iOS) naming the section, the effective style, and the fix:
539
+ A combination that can never render — `gridWrap` or `gridTile` outside a grid, `cardTint` with no card treatment in scope, `imageShape` anywhere but an image or condensed grid, `accessorySymbol` on a plain or card grid, `display` on a track that renders playable — is inert, not an error, so nothing breaks. In a **debug build**, each one is logged once per page resolution (`Timber` on Android, `os.log` on iOS) naming the section, the effective style, and the fix:
540
540
 
541
541
  ```
542
542
  Inert style declaration on page '/home': `style.gridWrap` on section 'Featured'
@@ -557,6 +557,8 @@ A property a _surface_ can't draw is never reported — that's the aspirational
557
557
  title: 'Home',
558
558
  sections: [
559
559
  { title: 'Popular', style: { display: 'grid', gridWrap: false }, path: '/popular', children: [...] },
560
+ // Round tiles for people: the image tile is the one with a shape.
561
+ { title: 'Artists', style: { display: 'grid', gridTile: 'image', imageShape: 'circular' }, children: [...] },
560
562
  { title: 'All stations', children: [...] }
561
563
  ]
562
564
  }
@@ -567,6 +569,10 @@ A property a _surface_ can't draw is never reported — that's the aspirational
567
569
  }
568
570
  ```
569
571
 
572
+ ::: tip See every style rendered
573
+ The example app's **Style Lab** tab draws each `style` declaration on CarPlay: one page per `gridTile` value, plus pages for section headers and for inheritance from page to section to track. Every shelf is titled with the block it renders, so what you see on screen names what to copy. The pages are plain route data in [`style-lab.ts`](https://github.com/radio-garden/react-native-audio-browser/blob/main/apps/example-native/src/utils/style-lab.ts), with a comment on each example saying what it shows.
574
+ :::
575
+
570
576
  Two config-level platform options round it out: `carPlayLoadingTitle` (localized "Loading…" on older CarPlay) and `androidControllerOfflineError`. The album-line tap needs both `albumPath` on the track and a `resolveAlbumPath` callback in the config that maps it to a browse path — see [CarPlay](/guide/carplay) and [Android Auto](/guide/android-auto) for the platform setup these build on.
571
577
 
572
578
  ## Where to go next
@@ -142,7 +142,7 @@ Optional fields that change how a Track renders on CarPlay / Android Auto. Each
142
142
  | ------------------------------------------ | ---------------------------------------------------------------------- | -------------------------- |
143
143
  | `style: { display: 'list' \| 'grid' }` | on a browsable track: the layout of the page it opens | Android Auto / AAOS |
144
144
  | `style: { artworkRendering: 'stencil' }` | tint monochrome artwork to the surface appearance | CarPlay |
145
- | `style: { imageShape: 'circular' }` | crop grid-tile artwork circular (people) instead of rounded | CarPlay (iOS 26+) |
145
+ | `style: { imageShape: 'circular' }` | crop `'image'` and `'condensed'` tile artwork circular (people) | CarPlay (iOS 26+) |
146
146
  | `style: { accessorySymbol: '…' }` | SF Symbol accessory; `'none'` restores the derived accessory | CarPlay |
147
147
  | `style: { cardTint: '…', cardImage: '…' }` | card color and image mode, when the section renders `gridTile: 'card'` | CarPlay (iOS 26+) |
148
148
  | `disabled` | unavailable: never plays; grayed where drawable, hidden elsewhere | all |