import Flutter import UIKit import AVFoundation import AVKit @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 var castingEventSink: FlutterEventSink? private var castingStatus: [String: Any] = [ "status": "idle", "platform": "ios" ] private var playbackMethodChannel: FlutterMethodChannel? 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() return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) guard let registrar = engineBridge.pluginRegistry.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() ) } /// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate. func invokePlay() { playbackMethodChannel?.invokeMethod("play", arguments: nil) } /// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate. func invokeStop() { playbackMethodChannel?.invokeMethod("stop", arguments: nil) } @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 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) } }