Files
kryz-go-flutter/ios/Runner/CarPlaySceneDelegate.swift

225 lines
6.6 KiB
Swift

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()
}
}