From e30087e3bd28315b3ffb153a9d570726dce7706e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 17:04:05 +0000 Subject: [PATCH] refactor(carplay): in-place template updates instead of setRootTemplate spam Call setRootTemplate exactly once (on attachInterfaceController), storing the three child CPListTemplates as bridge properties. Subsequent syncs from the JS coordinator (updateFavorites/updateLibrary/updatePlaylists) now call CPListTemplate.updateSections(_:) on the appropriate stored template rather than rebuilding the full CPTabBarTemplate hierarchy and re-setting the root. Adds RootTemplates struct and per-tab section-builder helpers to RootTemplateBuilder so the bridge can rebuild only items/sections without touching the parent templates. Actions are built once and capture [weak self]. Preserves all [CarPlay] NSLog instrumentation from 4f868a5. --- .../App/CarPlay/CarPlayDataBridge.swift | 90 ++++++++--- .../App/CarPlay/RootTemplateBuilder.swift | 141 +++++++++++------- 2 files changed, 157 insertions(+), 74 deletions(-) diff --git a/packages/ios/native/App/CarPlay/CarPlayDataBridge.swift b/packages/ios/native/App/CarPlay/CarPlayDataBridge.swift index db58e22f..5e2c318f 100644 --- a/packages/ios/native/App/CarPlay/CarPlayDataBridge.swift +++ b/packages/ios/native/App/CarPlay/CarPlayDataBridge.swift @@ -56,11 +56,18 @@ 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 } @@ -68,7 +75,25 @@ final class CarPlayDataBridge { 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") @@ -78,6 +103,10 @@ final class CarPlayDataBridge { NSLog("[CarPlay] detach controller") if interfaceController === controller { interfaceController = nil + favoritesTemplate = nil + libraryTemplate = nil + playlistsTemplate = nil + rootActions = nil } } @@ -86,7 +115,7 @@ 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 { @@ -94,7 +123,7 @@ final class CarPlayDataBridge { 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 { @@ -102,7 +131,7 @@ final class CarPlayDataBridge { 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 { @@ -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) }, @@ -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( diff --git a/packages/ios/native/App/CarPlay/RootTemplateBuilder.swift b/packages/ios/native/App/CarPlay/RootTemplateBuilder.swift index ec160491..6f07e03e 100644 --- a/packages/ios/native/App/CarPlay/RootTemplateBuilder.swift +++ b/packages/ios/native/App/CarPlay/RootTemplateBuilder.swift @@ -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 @@ -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) @@ -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)) @@ -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( @@ -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) @@ -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) @@ -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)