From b8e86263d94e065e9d01b98c359fbfc40b938fb9 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Sun, 17 May 2026 18:35:01 -0700 Subject: [PATCH] functional carplay experience in simulators --- ios/Runner/AppDelegate.swift | 270 +++++++++++++++++++++++++- ios/Runner/CarPlaySceneDelegate.swift | 102 +++++++++- lib/carplay_bridge.dart | 77 ++++++++ lib/kryz_audio_handler.dart | 7 + lib/main.dart | 5 + 5 files changed, 450 insertions(+), 11 deletions(-) create mode 100644 lib/carplay_bridge.dart diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 7b64419..8abf6c3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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() } } diff --git a/ios/Runner/CarPlaySceneDelegate.swift b/ios/Runner/CarPlaySceneDelegate.swift index 5d59e50..536479a 100644 --- a/ios/Runner/CarPlaySceneDelegate.swift +++ b/ios/Runner/CarPlaySceneDelegate.swift @@ -16,9 +16,44 @@ import UIKit */ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPListTemplateDelegate { + private struct Snapshot { + var playbackStatus: String + var title: String + var subtitle: String + var album: String + + init(playbackStatus: String, title: String, subtitle: String, album: String) { + self.playbackStatus = playbackStatus + self.title = title + self.subtitle = subtitle + self.album = album + } + + static let `default` = Snapshot( + playbackStatus: "stopped", + title: "KRYZ Live Stream", + subtitle: "KRYZ Radio", + album: "Live" + ) + + init(payload: [String: Any]?) { + let base = Snapshot.default + 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! : base.playbackStatus + self.title = (title?.isEmpty == false) ? title! : base.title + self.subtitle = (subtitle?.isEmpty == false) ? subtitle! : base.subtitle + self.album = (album?.isEmpty == false) ? album! : base.album + } + } + private var carPlayInterfaceController: CPInterfaceController? private var listTemplate: CPListTemplate? private var streamItem: CPListItem? + private var snapshot: Snapshot = .default // MARK: - CPTemplateApplicationSceneDelegate @@ -26,7 +61,10 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPL _ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController ) { + appDelegate?.ensureFlutterBridgeReadyForCarPlay() carPlayInterfaceController = interfaceController + snapshot = Snapshot(payload: appDelegate?.currentCarPlaySnapshot()) + let rootTemplate = makeListTemplate() if #available(iOS 14.0, *) { interfaceController.setRootTemplate(rootTemplate, animated: false, completion: nil) @@ -34,12 +72,25 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPL // iOS 13 fallback: use API variant available before iOS 14. interfaceController.setRootTemplate(rootTemplate, animated: false) } + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleCarPlaySnapshotChanged(_:)), + name: AppDelegate.carPlaySnapshotDidChangeNotification, + object: nil + ) + appDelegate?.requestCarPlaySnapshot() } func templateApplicationScene( _ templateApplicationScene: CPTemplateApplicationScene, didDisconnectInterfaceController interfaceController: CPInterfaceController ) { + NotificationCenter.default.removeObserver( + self, + name: AppDelegate.carPlaySnapshotDidChangeNotification, + object: nil + ) carPlayInterfaceController = nil listTemplate = nil appDelegate?.invokeStop() @@ -52,8 +103,8 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPL .withRenderingMode(.alwaysOriginal) let item = CPListItem( - text: "KRYZ Live Stream", - detailText: "KRYZ Radio", + text: snapshot.title, + detailText: detailText(for: snapshot), image: icon ) streamItem = item @@ -82,10 +133,57 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPL return template } + private func detailText(for snapshot: Snapshot) -> String { + let statusText: String = switch snapshot.playbackStatus { + case "playing": + "Playing" + case "buffering": + "Buffering" + default: + "Stopped" + } + + if snapshot.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return statusText + } + + return "\(snapshot.subtitle) • \(statusText)" + } + + @objc private func handleCarPlaySnapshotChanged(_ notification: Notification) { + guard + let payload = notification.userInfo?[AppDelegate.carPlaySnapshotUserInfoKey] as? [String: Any] + else { + return + } + + snapshot = Snapshot(payload: payload) + refreshTemplatesForSnapshot() + } + + private func refreshTemplatesForSnapshot() { + guard let interfaceController = carPlayInterfaceController else { + return + } + + if #available(iOS 14.0, *), interfaceController.topTemplate is CPNowPlayingTemplate { + // Preserve Now Playing navigation stack while metadata updates are flowing. + return + } + + let updatedTemplate = makeListTemplate() + if #available(iOS 14.0, *) { + interfaceController.setRootTemplate(updatedTemplate, animated: false, completion: nil) + } else { + interfaceController.setRootTemplate(updatedTemplate, animated: false) + } + } + // MARK: - Playback private func handleStreamItemTapped() { appDelegate?.invokePlay() + appDelegate?.requestCarPlaySnapshot() guard let interfaceController = carPlayInterfaceController else { return } diff --git a/lib/carplay_bridge.dart b/lib/carplay_bridge.dart new file mode 100644 index 0000000..c30fa9b --- /dev/null +++ b/lib/carplay_bridge.dart @@ -0,0 +1,77 @@ +import 'package:flutter/services.dart'; + +import 'streaming_audio_business_object.dart'; + +class CarPlayBridge { + CarPlayBridge({required StreamingAudioBusinessObject audio}) : _audio = audio; + + static const MethodChannel _channel = MethodChannel( + 'kryz_go_flutter/carplay/methods', + ); + + final StreamingAudioBusinessObject _audio; + bool _isStarted = false; + + Future start() async { + if (_isStarted) { + return; + } + + _isStarted = true; + _channel.setMethodCallHandler(_handleNativeMethodCall); + _audio.addListener(_onAudioChanged); + await _pushSnapshotToNative(); + } + + Future dispose() async { + if (!_isStarted) { + return; + } + + _isStarted = false; + _audio.removeListener(_onAudioChanged); + _channel.setMethodCallHandler(null); + } + + Map _snapshot() { + final String playbackStatus = switch (_audio.playbackStatus) { + StreamingPlaybackStatus.playing => 'playing', + StreamingPlaybackStatus.buffering => 'buffering', + StreamingPlaybackStatus.stopped => 'stopped', + }; + + return { + 'playbackStatus': playbackStatus, + 'isPlaying': _audio.isPlaying, + 'isBuffering': _audio.isBuffering, + 'isLive': true, + 'seekable': false, + 'title': _audio.currentTitle, + 'subtitle': _audio.currentSubtitle, + 'album': 'Live', + }; + } + + Future _handleNativeMethodCall(MethodCall call) async { + switch (call.method) { + case 'getStateSnapshot': + return _snapshot(); + default: + throw MissingPluginException('Unknown CarPlay bridge method: ${call.method}'); + } + } + + void _onAudioChanged() { + _pushSnapshotToNative(); + } + + Future _pushSnapshotToNative() async { + try { + await _channel.invokeMethod('syncState', _snapshot()); + } on MissingPluginException { + // CarPlay bridge only exists on iOS native builds. + } on PlatformException { + // Keep playback working even if native sync temporarily fails. + } + } +} \ No newline at end of file diff --git a/lib/kryz_audio_handler.dart b/lib/kryz_audio_handler.dart index 90836f6..b4ae3ae 100644 --- a/lib/kryz_audio_handler.dart +++ b/lib/kryz_audio_handler.dart @@ -126,6 +126,13 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler { ); } + @override + Future pause() async { + // CarPlay and lock-screen transport may issue pause for live streams. + // Treat pause as stop to keep behavior consistent across platforms. + await stop(); + } + @override Future seek(Duration position) async { // Live stream is intentionally non-seekable. diff --git a/lib/main.dart b/lib/main.dart index 546ed90..18ba307 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:timezone/data/latest.dart' as tz; import 'app_theme_business_object.dart'; +import 'carplay_bridge.dart'; import 'casting_business_object.dart'; import 'live_info_business_object.dart'; import 'live_info_panel.dart'; @@ -35,16 +36,20 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject(); final CastingBusinessObject _casting = CastingBusinessObject(); + late final CarPlayBridge _carPlayBridge; @override void initState() { super.initState(); + _carPlayBridge = CarPlayBridge(audio: _audio); unawaited(_audio.ensureInitialized()); + unawaited(_carPlayBridge.start()); _casting.initialize(); } @override void dispose() { + unawaited(_carPlayBridge.dispose()); _casting.dispose(); _audio.dispose(); super.dispose();