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] = [ "status": "idle", "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, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { // Ensure iOS uses the playback category so lock screen / Control Center // media controls remain available during background audio. do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.allowAirPlay]) try AVAudioSession.sharedInstance().setActive(true) } catch { NSLog("Failed to configure AVAudioSession: \(error)") } NotificationCenter.default.addObserver( self, selector: #selector(handleAudioRouteChange), name: AVAudioSession.routeChangeNotification, object: nil ) updateCastingStatusForCurrentRoute() ensureFlutterBridgeReadyForCarPlay() return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) configureChannels(with: engineBridge.pluginRegistry) 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 } let methodChannel = FlutterMethodChannel( name: castingMethodChannelName, binaryMessenger: registrar.messenger() ) let eventChannel = FlutterEventChannel( name: castingEventChannelName, binaryMessenger: registrar.messenger() ) eventChannel.setStreamHandler(self) methodChannel.setMethodCallHandler { [weak self] call, result in self?.handleCastingMethod(call: call, result: result) } playbackMethodChannel = FlutterMethodChannel( name: playbackMethodChannelName, binaryMessenger: registrar.messenger() ) carPlayMethodChannel = FlutterMethodChannel( name: carPlayMethodChannelName, binaryMessenger: registrar.messenger() ) carPlayMethodChannel?.setMethodCallHandler { [weak self] call, result in self?.handleCarPlayMethod(call: call, result: result) } configureRemoteCommandCenterIfNeeded() requestCarPlaySnapshot() } @objc private func handleAudioRouteChange() { updateCastingStatusForCurrentRoute() } private func handleCastingMethod(call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "getStatus": updateCastingStatusForCurrentRoute() result(castingStatus) case "showDevicePicker": showAirPlayPicker() result(nil) case "startCastingLiveStream": // AirPlay route selection controls where iOS outputs audio. No explicit load call needed. result(nil) case "disconnect": // iOS does not expose direct route disconnect. Users select route via picker. showAirPlayPicker() result(nil) default: result(FlutterMethodNotImplemented) } } 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 { self.emitCastingEvent(status: "error", message: "Unable to open AirPlay picker") return } let routePickerView = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) routePickerView.alpha = 0.01 rootViewController.view.addSubview(routePickerView) for subview in routePickerView.subviews { if let button = subview as? UIButton { button.sendActions(for: .touchUpInside) break } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { routePickerView.removeFromSuperview() } } } private func activeRootViewController() -> UIViewController? { let activeScene = UIApplication.shared.connectedScenes .first { $0.activationState == .foregroundActive } as? UIWindowScene let keyWindow = activeScene?.windows.first { $0.isKeyWindow } return keyWindow?.rootViewController } private func updateCastingStatusForCurrentRoute() { let currentOutputs = AVAudioSession.sharedInstance().currentRoute.outputs if let airPlayOutput = currentOutputs.first(where: { $0.portType == .airPlay }) { castingStatus = [ "status": "connected", "platform": "ios", "deviceName": airPlayOutput.portName ] } else { castingStatus = [ "status": "idle", "platform": "ios" ] } castingEventSink?(castingStatus) } private func emitCastingEvent(status: String, message: String) { let event: [String: Any] = [ "status": status, "platform": "ios", "message": message ] castingStatus = event castingEventSink?(event) } func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { castingEventSink = events events(castingStatus) return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { castingEventSink = nil return nil } deinit { NotificationCenter.default.removeObserver(self) teardownRemoteCommandCenter() } }