functional carplay experience in simulators

This commit is contained in:
2026-05-17 18:35:01 -07:00
parent e3288e044a
commit b8e86263d9
5 changed files with 450 additions and 11 deletions
+261 -9
View File
@@ -2,12 +2,76 @@ import Flutter
import UIKit
import AVFoundation
import AVKit
import MediaPlayer
private struct CarPlayPlaybackSnapshot {
var playbackStatus: String
var title: String
var subtitle: String
var album: String
var isLive: Bool
var seekable: Bool
static let `default` = CarPlayPlaybackSnapshot(
playbackStatus: "stopped",
title: "KRYZ Live Stream",
subtitle: "KRYZ Radio",
album: "Live",
isLive: true,
seekable: false
)
init(
playbackStatus: String,
title: String,
subtitle: String,
album: String,
isLive: Bool,
seekable: Bool
) {
self.playbackStatus = playbackStatus
self.title = title
self.subtitle = subtitle
self.album = album
self.isLive = isLive
self.seekable = seekable
}
init(payload: [String: Any], fallback: CarPlayPlaybackSnapshot) {
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! : fallback.playbackStatus
self.title = (title?.isEmpty == false) ? title! : fallback.title
self.subtitle = (subtitle?.isEmpty == false) ? subtitle! : fallback.subtitle
self.album = (album?.isEmpty == false) ? album! : fallback.album
isLive = payload["isLive"] as? Bool ?? fallback.isLive
seekable = payload["seekable"] as? Bool ?? fallback.seekable
}
func asDictionary() -> [String: Any] {
[
"playbackStatus": playbackStatus,
"title": title,
"subtitle": subtitle,
"album": album,
"isLive": isLive,
"seekable": seekable
]
}
}
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler {
private let castingMethodChannelName = "kryz_go_flutter/casting/methods"
private let castingEventChannelName = "kryz_go_flutter/casting/events"
private let playbackMethodChannelName = "kryz_go_flutter/playback/methods"
private let carPlayMethodChannelName = "kryz_go_flutter/carplay/methods"
static let carPlaySnapshotDidChangeNotification = Notification.Name("KRYZCarPlaySnapshotDidChange")
static let carPlaySnapshotUserInfoKey = "snapshot"
private var castingEventSink: FlutterEventSink?
private var castingStatus: [String: Any] = [
@@ -15,6 +79,13 @@ import AVKit
"platform": "ios"
]
private var playbackMethodChannel: FlutterMethodChannel?
private var carPlayMethodChannel: FlutterMethodChannel?
private var carPlaySnapshot = CarPlayPlaybackSnapshot.default
private var headlessFlutterEngine: FlutterEngine?
private var playCommandTarget: Any?
private var togglePlayPauseCommandTarget: Any?
private var pauseCommandTarget: Any?
private var stopCommandTarget: Any?
override func application(
_ application: UIApplication,
@@ -37,14 +108,67 @@ import AVKit
)
updateCastingStatusForCurrentRoute()
ensureFlutterBridgeReadyForCarPlay()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
configureChannels(with: engineBridge.pluginRegistry)
guard let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "CastingBridge") else {
if let engine = headlessFlutterEngine {
engine.destroyContext()
headlessFlutterEngine = nil
}
}
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokePlay() {
ensureFlutterBridgeReadyForCarPlay()
playbackMethodChannel?.invokeMethod("play", arguments: nil)
}
/// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokeStop() {
ensureFlutterBridgeReadyForCarPlay()
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
}
func currentCarPlaySnapshot() -> [String: Any] {
carPlaySnapshot.asDictionary()
}
func requestCarPlaySnapshot() {
ensureFlutterBridgeReadyForCarPlay()
carPlayMethodChannel?.invokeMethod("getStateSnapshot", arguments: nil) { [weak self] response in
guard let payload = response as? [String: Any] else {
return
}
self?.applyCarPlaySnapshot(payload)
}
}
func ensureFlutterBridgeReadyForCarPlay() {
if playbackMethodChannel != nil, carPlayMethodChannel != nil {
return
}
if let engine = headlessFlutterEngine {
configureChannels(with: engine)
return
}
let engine = FlutterEngine(name: "kryz_carplay_headless_engine", project: nil, allowHeadlessExecution: true)
headlessFlutterEngine = engine
engine.run()
GeneratedPluginRegistrant.register(with: engine)
configureChannels(with: engine)
}
private func configureChannels(with registry: FlutterPluginRegistry) {
guard let registrar = registry.registrar(forPlugin: "CastingBridge") else {
return
}
@@ -67,16 +191,17 @@ import AVKit
name: playbackMethodChannelName,
binaryMessenger: registrar.messenger()
)
}
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokePlay() {
playbackMethodChannel?.invokeMethod("play", arguments: nil)
}
carPlayMethodChannel = FlutterMethodChannel(
name: carPlayMethodChannelName,
binaryMessenger: registrar.messenger()
)
carPlayMethodChannel?.setMethodCallHandler { [weak self] call, result in
self?.handleCarPlayMethod(call: call, result: result)
}
/// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate.
func invokeStop() {
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
configureRemoteCommandCenterIfNeeded()
requestCarPlaySnapshot()
}
@objc private func handleAudioRouteChange() {
@@ -103,6 +228,132 @@ import AVKit
}
}
private func handleCarPlayMethod(call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "syncState":
guard let payload = call.arguments as? [String: Any] else {
result(
FlutterError(
code: "invalid_payload",
message: "Expected map payload for syncState",
details: nil
)
)
return
}
applyCarPlaySnapshot(payload)
result(nil)
case "getCachedState":
result(carPlaySnapshot.asDictionary())
default:
result(FlutterMethodNotImplemented)
}
}
private func applyCarPlaySnapshot(_ payload: [String: Any]) {
carPlaySnapshot = CarPlayPlaybackSnapshot(payload: payload, fallback: carPlaySnapshot)
updateNowPlayingInfoCenter(using: carPlaySnapshot)
NotificationCenter.default.post(
name: Self.carPlaySnapshotDidChangeNotification,
object: self,
userInfo: [Self.carPlaySnapshotUserInfoKey: carPlaySnapshot.asDictionary()]
)
}
private func updateNowPlayingInfoCenter(using snapshot: CarPlayPlaybackSnapshot) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
nowPlayingInfo[MPMediaItemPropertyTitle] = snapshot.title
nowPlayingInfo[MPMediaItemPropertyArtist] = snapshot.subtitle
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = snapshot.album
nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = snapshot.isLive
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = snapshot.playbackStatus == "playing" ? 1.0 : 0.0
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
if #available(iOS 13.0, *) {
let playbackState: MPNowPlayingPlaybackState = switch snapshot.playbackStatus {
case "playing":
.playing
case "buffering":
.playing
default:
.stopped
}
MPNowPlayingInfoCenter.default().playbackState = playbackState
}
}
private func configureRemoteCommandCenterIfNeeded() {
if playCommandTarget != nil ||
togglePlayPauseCommandTarget != nil ||
pauseCommandTarget != nil ||
stopCommandTarget != nil {
return
}
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.changePlaybackPositionCommand.isEnabled = false
commandCenter.seekForwardCommand.isEnabled = false
commandCenter.seekBackwardCommand.isEnabled = false
commandCenter.nextTrackCommand.isEnabled = false
commandCenter.previousTrackCommand.isEnabled = false
commandCenter.playCommand.isEnabled = true
playCommandTarget = commandCenter.playCommand.addTarget { [weak self] _ in
self?.invokePlay()
return .success
}
commandCenter.togglePlayPauseCommand.isEnabled = true
togglePlayPauseCommandTarget = commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
guard let self else {
return .commandFailed
}
switch self.carPlaySnapshot.playbackStatus {
case "playing", "buffering":
self.invokeStop()
default:
self.invokePlay()
}
return .success
}
commandCenter.pauseCommand.isEnabled = true
pauseCommandTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.invokeStop()
return .success
}
commandCenter.stopCommand.isEnabled = true
stopCommandTarget = commandCenter.stopCommand.addTarget { [weak self] _ in
self?.invokeStop()
return .success
}
}
private func teardownRemoteCommandCenter() {
let commandCenter = MPRemoteCommandCenter.shared()
if let target = playCommandTarget {
commandCenter.playCommand.removeTarget(target)
}
if let target = togglePlayPauseCommandTarget {
commandCenter.togglePlayPauseCommand.removeTarget(target)
}
if let target = pauseCommandTarget {
commandCenter.pauseCommand.removeTarget(target)
}
if let target = stopCommandTarget {
commandCenter.stopCommand.removeTarget(target)
}
playCommandTarget = nil
togglePlayPauseCommandTarget = nil
pauseCommandTarget = nil
stopCommandTarget = nil
}
private func showAirPlayPicker() {
DispatchQueue.main.async {
guard let rootViewController = self.activeRootViewController() else {
@@ -176,5 +427,6 @@ import AVKit
deinit {
NotificationCenter.default.removeObserver(self)
teardownRemoteCommandCenter()
}
}