Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 68 additions & 22 deletions packages/ios/native/App/CarPlay/CarPlayDataBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,44 @@ final class CarPlayDataBridge {
private weak var interfaceController: CPInterfaceController?
private var eventSink: ((String, [String: Any]) -> Void)?

// Snapshot state — written by the JS sync calls, read by template builders
private(set) var favorites: [CarPlayTrackSnapshot] = []
private(set) var libraryBuckets: [CarPlayLibraryBucketSnapshot] = []
private(set) var playlists: [CarPlayPlaylistSnapshot] = []
private(set) var nowPlaying: CarPlayNowPlayingSnapshot?

// Root templates — created once in attachInterfaceController, mutated in-place via updateSections
private var favoritesTemplate: CPListTemplate?
private var libraryTemplate: CPListTemplate?
private var playlistsTemplate: CPListTemplate?
private var rootActions: RootTemplateBuilder.Actions?

func setEventSink(_ sink: @escaping (String, [String: Any]) -> Void) {
eventSink = sink
}

func attachInterfaceController(_ controller: CPInterfaceController) {
NSLog("[CarPlay] attach controller")
interfaceController = controller
refreshRootTemplate(animated: false)

let actions = makeActions()
rootActions = actions

let root = RootTemplateBuilder.buildRootTemplate(
state: CarPlayTemplateState(
favorites: favorites,
libraryBuckets: libraryBuckets,
playlists: playlists,
nowPlaying: nowPlaying
),
actions: actions
)
favoritesTemplate = root.favorites
libraryTemplate = root.library
playlistsTemplate = root.playlists

controller.setRootTemplate(root.tabBar, animated: false, completion: nil)
NSLog("[CarPlay] setRoot once favs=%d buckets=%d playlists=%d", favorites.count, libraryBuckets.count, playlists.count)
NSLog("[CarPlay] initial empty root rendered")
eventSink?("carPlayConnected", [:])
NSLog("[CarPlay] sent carPlayConnected eventSink=%@", eventSink == nil ? "nil" : "set")
Expand All @@ -78,6 +103,10 @@ final class CarPlayDataBridge {
NSLog("[CarPlay] detach controller")
if interfaceController === controller {
interfaceController = nil
favoritesTemplate = nil
libraryTemplate = nil
playlistsTemplate = nil
rootActions = nil
}
}

Expand All @@ -86,23 +115,23 @@ final class CarPlayDataBridge {
let data = Data(json.utf8)
libraryBuckets = try JSONDecoder().decode([CarPlayLibraryBucketSnapshot].self, from: data)
NSLog("[CarPlay] updateLibrary decoded buckets=%d", libraryBuckets.count)
refreshRootTemplate()
updateLibraryTemplate()
}

func updateFavorites(from json: String) throws {
NSLog("[CarPlay] updateFavorites bytes=%d", json.utf8.count)
let data = Data(json.utf8)
favorites = try JSONDecoder().decode([CarPlayTrackSnapshot].self, from: data)
NSLog("[CarPlay] updateFavorites decoded count=%d", favorites.count)
refreshRootTemplate()
updateFavoritesTemplate()
}

func updatePlaylists(from json: String) throws {
NSLog("[CarPlay] updatePlaylists bytes=%d", json.utf8.count)
let data = Data(json.utf8)
playlists = try JSONDecoder().decode([CarPlayPlaylistSnapshot].self, from: data)
NSLog("[CarPlay] updatePlaylists decoded count=%d", playlists.count)
refreshRootTemplate()
updatePlaylistsTemplate()
}

func updateNowPlaying(from json: String?) throws {
Expand All @@ -123,20 +152,19 @@ final class CarPlayDataBridge {
libraryBuckets = []
playlists = []
nowPlaying = nil
refreshRootTemplate()
updateFavoritesTemplate()
updateLibraryTemplate()
updatePlaylistsTemplate()
}

func showNowPlaying() {
interfaceController?.pushTemplate(CPNowPlayingTemplate.shared, animated: true, completion: nil)
}

private func refreshRootTemplate(animated: Bool = true) {
guard let controller = interfaceController else {
NSLog("[CarPlay] refreshRoot skipped: no controller")
return
}
// MARK: - Private

let actions = RootTemplateBuilder.Actions(
private func makeActions() -> RootTemplateBuilder.Actions {
RootTemplateBuilder.Actions(
pushTemplate: { [weak self] template in
self?.interfaceController?.pushTemplate(template, animated: true, completion: nil)
},
Expand Down Expand Up @@ -180,18 +208,36 @@ final class CarPlayDataBridge {
])
}
)
}

let root = RootTemplateBuilder.buildRootTemplate(
state: CarPlayTemplateState(
favorites: favorites,
libraryBuckets: libraryBuckets,
playlists: playlists,
nowPlaying: nowPlaying
),
actions: actions
)
controller.setRootTemplate(root, animated: animated, completion: nil)
NSLog("[CarPlay] setRoot favs=%d buckets=%d playlists=%d", favorites.count, libraryBuckets.count, playlists.count)
private func updateFavoritesTemplate() {
guard let template = favoritesTemplate, let actions = rootActions else {
NSLog("[CarPlay] updateFavoritesTemplate skipped: no template")
return
}
let sections = RootTemplateBuilder.makeFavoritesSections(favorites: favorites, actions: actions)
template.updateSections(sections)
NSLog("[CarPlay] updateSections favorites count=%d", favorites.count)
}

private func updateLibraryTemplate() {
guard let template = libraryTemplate, let actions = rootActions else {
NSLog("[CarPlay] updateLibraryTemplate skipped: no template")
return
}
let sections = RootTemplateBuilder.makeLibrarySections(buckets: libraryBuckets, actions: actions)
template.updateSections(sections)
NSLog("[CarPlay] updateSections library buckets=%d", libraryBuckets.count)
}

private func updatePlaylistsTemplate() {
guard let template = playlistsTemplate, let actions = rootActions else {
NSLog("[CarPlay] updatePlaylistsTemplate skipped: no template")
return
}
let sections = RootTemplateBuilder.makePlaylistsSections(playlists: playlists, actions: actions)
template.updateSections(sections)
NSLog("[CarPlay] updateSections playlists count=%d", playlists.count)
}

private func emitLibrarySelection(
Expand Down
141 changes: 89 additions & 52 deletions packages/ios/native/App/CarPlay/RootTemplateBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@ enum RootTemplateBuilder {
let onPlaylistTrackSelected: (String, CarPlayTrackSnapshot) -> Void
}

struct RootTemplates {
let tabBar: CPTabBarTemplate
let favorites: CPListTemplate
let library: CPListTemplate
let playlists: CPListTemplate
}

static func buildRootTemplate(
state: CarPlayTemplateState,
actions: Actions
) -> CPTabBarTemplate {
) -> RootTemplates {
let favoritesTemplate = makeFavoritesTemplate(
favorites: state.favorites,
actions: actions
Expand All @@ -29,15 +36,22 @@ enum RootTemplateBuilder {
playlists: state.playlists,
actions: actions
)

return CPTabBarTemplate(templates: [favoritesTemplate, libraryTemplate, playlistsTemplate])
let tabBar = CPTabBarTemplate(templates: [favoritesTemplate, libraryTemplate, playlistsTemplate])
return RootTemplates(
tabBar: tabBar,
favorites: favoritesTemplate,
library: libraryTemplate,
playlists: playlistsTemplate
)
}

private static func makeFavoritesTemplate(
// MARK: - Section builders (called per-update for in-place CPListTemplate.updateSections)

static func makeFavoritesSections(
favorites: [CarPlayTrackSnapshot],
actions: Actions
) -> CPListTemplate {
let items = favorites.isEmpty
) -> [CPListSection] {
let items: [CPListItem] = favorites.isEmpty
? [placeholderItem(text: "Favorite tracks appear here.")]
: favorites.map { track in
let item = CPListItem(text: track.title, detailText: track.subtitle)
Expand All @@ -48,20 +62,14 @@ enum RootTemplateBuilder {
}
return item
}

return makeListTemplate(
title: "Favorites",
tabTitle: "Favorites",
tabImageName: "heart.fill",
items: items
)
return [CPListSection(items: items)]
}

private static func makeLibraryTemplate(
static func makeLibrarySections(
buckets: [CarPlayLibraryBucketSnapshot],
actions: Actions
) -> CPListTemplate {
let items = buckets.isEmpty
) -> [CPListSection] {
let items: [CPListItem] = buckets.isEmpty
? [placeholderItem(text: "Open Familiar on your iPhone to load CarPlay.")]
: buckets.map { bucket in
let item = CPListItem(text: bucket.title, detailText: detailText(for: bucket))
Expand Down Expand Up @@ -89,13 +97,71 @@ enum RootTemplateBuilder {
}
return item
}
return [CPListSection(items: items)]
}

return makeListTemplate(
static func makePlaylistsSections(
playlists: [CarPlayPlaylistSnapshot],
actions: Actions
) -> [CPListSection] {
let items: [CPListItem] = playlists.isEmpty
? [placeholderItem(text: "No playlists available yet.")]
: playlists.map { playlist in
let item = CPListItem(text: playlist.title, detailText: playlist.subtitle)
item.handler = { _, completion in
actions.onPlaylistSelected(playlist)
let detailTemplate = makePlaylistTrackTemplate(
title: playlist.title,
playlist: playlist,
actions: actions
)
actions.pushTemplate(detailTemplate)
completion()
}
return item
}
return [CPListSection(items: items)]
}

// MARK: - Initial template builders (called once on attach)

private static func makeFavoritesTemplate(
favorites: [CarPlayTrackSnapshot],
actions: Actions
) -> CPListTemplate {
let template = CPListTemplate(
title: "Favorites",
sections: makeFavoritesSections(favorites: favorites, actions: actions)
)
template.tabTitle = "Favorites"
template.tabImage = UIImage(systemName: "heart.fill")
return template
}

private static func makeLibraryTemplate(
buckets: [CarPlayLibraryBucketSnapshot],
actions: Actions
) -> CPListTemplate {
let template = CPListTemplate(
title: "Library",
tabTitle: "Library",
tabImageName: "music.note.list",
items: items
sections: makeLibrarySections(buckets: buckets, actions: actions)
)
template.tabTitle = "Library"
template.tabImage = UIImage(systemName: "music.note.list")
return template
}

private static func makePlaylistsTemplate(
playlists: [CarPlayPlaylistSnapshot],
actions: Actions
) -> CPListTemplate {
let template = CPListTemplate(
title: "Playlists",
sections: makePlaylistsSections(playlists: playlists, actions: actions)
)
template.tabTitle = "Playlists"
template.tabImage = UIImage(systemName: "music.note")
return template
}

private static func makeCollectionTemplate(
Expand All @@ -104,7 +170,7 @@ enum RootTemplateBuilder {
collections: [CarPlayCollectionSnapshot],
actions: Actions
) -> CPListTemplate {
let items = collections.isEmpty
let items: [CPListItem] = collections.isEmpty
? [placeholderItem(text: "Nothing to show yet.")]
: collections.map { collection in
let item = CPListItem(text: collection.title, detailText: collection.subtitle)
Expand Down Expand Up @@ -138,7 +204,7 @@ enum RootTemplateBuilder {
tracks: [CarPlayTrackSnapshot],
actions: Actions
) -> CPListTemplate {
let items = tracks.isEmpty
let items: [CPListItem] = tracks.isEmpty
? [placeholderItem(text: "Nothing to play yet.")]
: tracks.map { track in
let item = CPListItem(text: track.title, detailText: track.subtitle)
Expand All @@ -158,41 +224,12 @@ enum RootTemplateBuilder {
)
}

private static func makePlaylistsTemplate(
playlists: [CarPlayPlaylistSnapshot],
actions: Actions
) -> CPListTemplate {
let items = playlists.isEmpty
? [placeholderItem(text: "No playlists available yet.")]
: playlists.map { playlist in
let item = CPListItem(text: playlist.title, detailText: playlist.subtitle)
item.handler = { _, completion in
actions.onPlaylistSelected(playlist)
let detailTemplate = makePlaylistTrackTemplate(
title: playlist.title,
playlist: playlist,
actions: actions
)
actions.pushTemplate(detailTemplate)
completion()
}
return item
}

return makeListTemplate(
title: "Playlists",
tabTitle: "Playlists",
tabImageName: "music.note",
items: items
)
}

private static func makePlaylistTrackTemplate(
title: String,
playlist: CarPlayPlaylistSnapshot,
actions: Actions
) -> CPListTemplate {
let items = playlist.tracks.isEmpty
let items: [CPListItem] = playlist.tracks.isEmpty
? [placeholderItem(text: "No tracks in this playlist.")]
: playlist.tracks.map { track in
let item = CPListItem(text: track.title, detailText: track.subtitle)
Expand Down
Loading