225 lines
6.6 KiB
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()
|
|
}
|
|
}
|