181 lines
5.5 KiB
Swift
181 lines
5.5 KiB
Swift
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)
|
|
}
|
|
}
|