433 lines
14 KiB
Swift
433 lines
14 KiB
Swift
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()
|
|
}
|
|
}
|