import CarPlay import UIKit /** * CarPlay scene delegate for KRYZ Go! * * Shows a CPListTemplate with the KRYZ Live Stream item. Tapping it triggers * playback via AppDelegate.invokePlay(), then presents CPNowPlayingTemplate. * * CarPlay enforces its own design system for safety — colors cannot be customised. * The app icon (which carries the KRYZ brand orange) is used as item artwork to * provide visual continuity with the phone app. * * Note: the com.apple.developer.carplay-audio entitlement must be enabled in the * Apple Developer Portal and added to the target in Xcode Signing & Capabilities. */ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPListTemplateDelegate { private struct Snapshot { var playbackStatus: String var title: String var subtitle: String var album: String init(playbackStatus: String, title: String, subtitle: String, album: String) { self.playbackStatus = playbackStatus self.title = title self.subtitle = subtitle self.album = album } static let `default` = Snapshot( playbackStatus: "stopped", title: "KRYZ Live Stream", subtitle: "KRYZ Radio", album: "Live" ) init(payload: [String: Any]?) { let base = Snapshot.default let status = (payload?["playbackStatus"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let title = (payload?["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let subtitle = (payload?["subtitle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let album = (payload?["album"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) playbackStatus = (status?.isEmpty == false) ? status! : base.playbackStatus self.title = (title?.isEmpty == false) ? title! : base.title self.subtitle = (subtitle?.isEmpty == false) ? subtitle! : base.subtitle self.album = (album?.isEmpty == false) ? album! : base.album } } private var carPlayInterfaceController: CPInterfaceController? private var listTemplate: CPListTemplate? private var streamItem: CPListItem? private var snapshot: Snapshot = .default // MARK: - CPTemplateApplicationSceneDelegate func templateApplicationScene( _ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController ) { appDelegate?.ensureFlutterBridgeReadyForCarPlay() carPlayInterfaceController = interfaceController snapshot = Snapshot(payload: appDelegate?.currentCarPlaySnapshot()) let rootTemplate = makeListTemplate() if #available(iOS 14.0, *) { interfaceController.setRootTemplate(rootTemplate, animated: false, completion: nil) } else { // iOS 13 fallback: use API variant available before iOS 14. interfaceController.setRootTemplate(rootTemplate, animated: false) } NotificationCenter.default.addObserver( self, selector: #selector(handleCarPlaySnapshotChanged(_:)), name: AppDelegate.carPlaySnapshotDidChangeNotification, object: nil ) appDelegate?.requestCarPlaySnapshot() } func templateApplicationScene( _ templateApplicationScene: CPTemplateApplicationScene, didDisconnectInterfaceController interfaceController: CPInterfaceController ) { NotificationCenter.default.removeObserver( self, name: AppDelegate.carPlaySnapshotDidChangeNotification, object: nil ) carPlayInterfaceController = nil listTemplate = nil appDelegate?.invokeStop() } // MARK: - Template construction private func makeListTemplate() -> CPListTemplate { let icon = UIImage(named: "AppIcon")? .withRenderingMode(.alwaysOriginal) let item = CPListItem( text: snapshot.title, detailText: detailText(for: snapshot), image: icon ) streamItem = item if #available(iOS 14.0, *) { item.handler = { [weak self] _, completion in self?.handleStreamItemTapped() completion() } } let section = CPListSection(items: [item]) let template = CPListTemplate(title: "KRYZ Go!", sections: [section]) if #unavailable(iOS 14.0) { // iOS 13 fallback: use list delegate selection callback. template.delegate = self } if #available(iOS 14.0, *) { template.tabTitle = "KRYZ Go!" template.tabImage = icon } listTemplate = template return template } private func detailText(for snapshot: Snapshot) -> String { let statusText: String = switch snapshot.playbackStatus { case "playing": "Playing" case "buffering": "Buffering" default: "Stopped" } if snapshot.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return statusText } return "\(snapshot.subtitle) • \(statusText)" } @objc private func handleCarPlaySnapshotChanged(_ notification: Notification) { guard let payload = notification.userInfo?[AppDelegate.carPlaySnapshotUserInfoKey] as? [String: Any] else { return } snapshot = Snapshot(payload: payload) refreshTemplatesForSnapshot() } private func refreshTemplatesForSnapshot() { guard let interfaceController = carPlayInterfaceController else { return } if #available(iOS 14.0, *), interfaceController.topTemplate is CPNowPlayingTemplate { // Preserve Now Playing navigation stack while metadata updates are flowing. return } let updatedTemplate = makeListTemplate() if #available(iOS 14.0, *) { interfaceController.setRootTemplate(updatedTemplate, animated: false, completion: nil) } else { interfaceController.setRootTemplate(updatedTemplate, animated: false) } } // MARK: - Playback private func handleStreamItemTapped() { appDelegate?.invokePlay() appDelegate?.requestCarPlaySnapshot() guard let interfaceController = carPlayInterfaceController else { return } if #available(iOS 14.0, *) { interfaceController.pushTemplate( CPNowPlayingTemplate.shared, animated: true, completion: nil ) } else { // iOS 13 fallback: no CPNowPlayingTemplate.shared API. // Keep playback running and remain on the list template. } } // MARK: - Helpers private var appDelegate: AppDelegate? { UIApplication.shared.delegate as? AppDelegate } // MARK: - CPListTemplateDelegate (iOS 13 fallback) func listTemplate( _ listTemplate: CPListTemplate, didSelect item: CPListItem, completionHandler: @escaping () -> Void ) { defer { completionHandler() } guard item == streamItem else { return } handleStreamItemTapped() } }