functional carplay experience in simulators
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user