6 changed files with 542 additions and 51 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"chat.tools.terminal.autoApprove": {
"flutter": true
}
}
+261 -9
View File
@@ -2,12 +2,76 @@ import Flutter
import UIKit import UIKit
import AVFoundation import AVFoundation
import AVKit 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 @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler {
private let castingMethodChannelName = "kryz_go_flutter/casting/methods" private let castingMethodChannelName = "kryz_go_flutter/casting/methods"
private let castingEventChannelName = "kryz_go_flutter/casting/events" private let castingEventChannelName = "kryz_go_flutter/casting/events"
private let playbackMethodChannelName = "kryz_go_flutter/playback/methods" 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 castingEventSink: FlutterEventSink?
private var castingStatus: [String: Any] = [ private var castingStatus: [String: Any] = [
@@ -15,6 +79,13 @@ import AVKit
"platform": "ios" "platform": "ios"
] ]
private var playbackMethodChannel: FlutterMethodChannel? 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( override func application(
_ application: UIApplication, _ application: UIApplication,
@@ -37,14 +108,67 @@ import AVKit
) )
updateCastingStatusForCurrentRoute() updateCastingStatusForCurrentRoute()
ensureFlutterBridgeReadyForCarPlay()
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) 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 return
} }
@@ -67,16 +191,17 @@ import AVKit
name: playbackMethodChannelName, name: playbackMethodChannelName,
binaryMessenger: registrar.messenger() binaryMessenger: registrar.messenger()
) )
}
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate. carPlayMethodChannel = FlutterMethodChannel(
func invokePlay() { name: carPlayMethodChannelName,
playbackMethodChannel?.invokeMethod("play", arguments: nil) 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. configureRemoteCommandCenterIfNeeded()
func invokeStop() { requestCarPlaySnapshot()
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
} }
@objc private func handleAudioRouteChange() { @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() { private func showAirPlayPicker() {
DispatchQueue.main.async { DispatchQueue.main.async {
guard let rootViewController = self.activeRootViewController() else { guard let rootViewController = self.activeRootViewController() else {
@@ -176,5 +427,6 @@ import AVKit
deinit { deinit {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
teardownRemoteCommandCenter()
} }
} }
+156 -17
View File
@@ -14,10 +14,46 @@ import UIKit
* Note: the com.apple.developer.carplay-audio entitlement must be enabled in the * Note: the com.apple.developer.carplay-audio entitlement must be enabled in the
* Apple Developer Portal and added to the target in Xcode Signing & Capabilities. * Apple Developer Portal and added to the target in Xcode Signing & Capabilities.
*/ */
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { 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 carPlayInterfaceController: CPInterfaceController?
private var listTemplate: CPListTemplate? private var listTemplate: CPListTemplate?
private var streamItem: CPListItem?
private var snapshot: Snapshot = .default
// MARK: - CPTemplateApplicationSceneDelegate // MARK: - CPTemplateApplicationSceneDelegate
@@ -25,14 +61,36 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
_ templateApplicationScene: CPTemplateApplicationScene, _ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController didConnect interfaceController: CPInterfaceController
) { ) {
appDelegate?.ensureFlutterBridgeReadyForCarPlay()
carPlayInterfaceController = interfaceController carPlayInterfaceController = interfaceController
interfaceController.setRootTemplate(makeListTemplate(), animated: false, completion: nil) snapshot = Snapshot(payload: appDelegate?.currentCarPlaySnapshot())
let rootTemplate = makeListTemplate()
if #available(iOS 14.0, *) {
interfaceController.setRootTemplate(rootTemplate, animated: false, completion: nil)
} else {
// 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( func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene, _ templateApplicationScene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController didDisconnectInterfaceController interfaceController: CPInterfaceController
) { ) {
NotificationCenter.default.removeObserver(
self,
name: AppDelegate.carPlaySnapshotDidChangeNotification,
object: nil
)
carPlayInterfaceController = nil carPlayInterfaceController = nil
listTemplate = nil listTemplate = nil
appDelegate?.invokeStop() appDelegate?.invokeStop()
@@ -41,40 +99,105 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
// MARK: - Template construction // MARK: - Template construction
private func makeListTemplate() -> CPListTemplate { private func makeListTemplate() -> CPListTemplate {
let artwork = CPListItem.maximumImageSize
let icon = UIImage(named: "AppIcon")? let icon = UIImage(named: "AppIcon")?
.withRenderingMode(.alwaysOriginal) .withRenderingMode(.alwaysOriginal)
let item = CPListItem( let item = CPListItem(
text: "KRYZ Live Stream", text: snapshot.title,
detailText: "KRYZ Radio", detailText: detailText(for: snapshot),
image: icon image: icon
) )
item.handler = { [weak self] _, completion in streamItem = item
self?.handleStreamItemTapped()
completion() if #available(iOS 14.0, *) {
item.handler = { [weak self] _, completion in
self?.handleStreamItemTapped()
completion()
}
} }
let section = CPListSection(items: [item]) let section = CPListSection(items: [item])
let template = CPListTemplate(title: "KRYZ Go!", sections: [section]) let template = CPListTemplate(title: "KRYZ Go!", sections: [section])
template.tabTitle = "KRYZ Go!"
template.tabImage = icon if #unavailable(iOS 14.0) {
// iOS 13 fallback: use list delegate selection callback.
template.delegate = self
}
if #available(iOS 14.0, *) {
template.tabTitle = "KRYZ Go!"
template.tabImage = icon
}
listTemplate = template listTemplate = template
// Silence unused-variable warning
_ = artwork
return template 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 // MARK: - Playback
private func handleStreamItemTapped() { private func handleStreamItemTapped() {
appDelegate?.invokePlay() appDelegate?.invokePlay()
carPlayInterfaceController?.pushTemplate( appDelegate?.requestCarPlaySnapshot()
CPNowPlayingTemplate.shared, guard let interfaceController = carPlayInterfaceController else {
animated: true, return
completion: nil }
)
if #available(iOS 14.0, *) {
interfaceController.pushTemplate(
CPNowPlayingTemplate.shared,
animated: true,
completion: nil
)
} else {
// iOS 13 fallback: no CPNowPlayingTemplate.shared API.
// Keep playback running and remain on the list template.
}
} }
// MARK: - Helpers // MARK: - Helpers
@@ -82,4 +205,20 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
private var appDelegate: AppDelegate? { private var appDelegate: AppDelegate? {
UIApplication.shared.delegate as? AppDelegate UIApplication.shared.delegate as? AppDelegate
} }
// MARK: - CPListTemplateDelegate (iOS 13 fallback)
func listTemplate(
_ listTemplate: CPListTemplate,
didSelect item: CPListItem,
completionHandler: @escaping () -> Void
) {
defer { completionHandler() }
guard item == streamItem else {
return
}
handleStreamItemTapped()
}
} }
+79
View File
@@ -0,0 +1,79 @@
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<void> start() async {
if (_isStarted) {
return;
}
_isStarted = true;
_channel.setMethodCallHandler(_handleNativeMethodCall);
_audio.addListener(_onAudioChanged);
await _pushSnapshotToNative();
}
Future<void> dispose() async {
if (!_isStarted) {
return;
}
_isStarted = false;
_audio.removeListener(_onAudioChanged);
_channel.setMethodCallHandler(null);
}
Map<String, dynamic> _snapshot() {
final String playbackStatus = switch (_audio.playbackStatus) {
StreamingPlaybackStatus.playing => 'playing',
StreamingPlaybackStatus.buffering => 'buffering',
StreamingPlaybackStatus.stopped => 'stopped',
};
return <String, dynamic>{
'playbackStatus': playbackStatus,
'isPlaying': _audio.isPlaying,
'isBuffering': _audio.isBuffering,
'isLive': true,
'seekable': false,
'title': _audio.currentTitle,
'subtitle': _audio.currentSubtitle,
'album': 'Live',
};
}
Future<dynamic> _handleNativeMethodCall(MethodCall call) async {
switch (call.method) {
case 'getStateSnapshot':
return _snapshot();
default:
throw MissingPluginException(
'Unknown CarPlay bridge method: ${call.method}',
);
}
}
void _onAudioChanged() {
_pushSnapshotToNative();
}
Future<void> _pushSnapshotToNative() async {
try {
await _channel.invokeMethod<void>('syncState', _snapshot());
} on MissingPluginException {
// CarPlay bridge only exists on iOS native builds.
} on PlatformException {
// Keep playback working even if native sync temporarily fails.
}
}
}
+33 -21
View File
@@ -18,7 +18,8 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a'; static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a';
static const String mediaId = 'kryz-live-stream'; static const String mediaId = 'kryz-live-stream';
static const String _liveInfoEndpoint = 'http://kryz.airtime.pro/api/live-info'; static const String _liveInfoEndpoint =
'http://kryz.airtime.pro/api/live-info';
static const Duration _liveInfoTimeout = Duration(seconds: 8); static const Duration _liveInfoTimeout = Duration(seconds: 8);
static const Duration _metadataRefreshInterval = Duration(seconds: 20); static const Duration _metadataRefreshInterval = Duration(seconds: 20);
@@ -27,10 +28,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
title: 'KRYZ Live Stream', title: 'KRYZ Live Stream',
artist: 'KRYZ Radio', artist: 'KRYZ Radio',
album: 'Live', album: 'Live',
extras: <String, dynamic>{ extras: <String, dynamic>{'isLive': true, 'seekable': false},
'isLive': true,
'seekable': false,
},
); );
final AudioPlayer _player = AudioPlayer(); final AudioPlayer _player = AudioPlayer();
@@ -82,10 +80,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
try { try {
if (!_isSourceLoaded) { if (!_isSourceLoaded) {
await _player.setAudioSource( await _player.setAudioSource(
AudioSource.uri( AudioSource.uri(Uri.parse(streamUrl), tag: mediaItem.value),
Uri.parse(streamUrl),
tag: mediaItem.value,
),
); );
_isSourceLoaded = true; _isSourceLoaded = true;
} }
@@ -126,6 +121,13 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
); );
} }
@override
Future<void> 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 @override
Future<void> seek(Duration position) async { Future<void> seek(Duration position) async {
// Live stream is intentionally non-seekable. // Live stream is intentionally non-seekable.
@@ -158,25 +160,35 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
} }
void _onPlayerStateChanged(PlayerState state) { void _onPlayerStateChanged(PlayerState state) {
final AudioProcessingState processingState = switch (state.processingState) { final AudioProcessingState processingState =
ProcessingState.idle => AudioProcessingState.idle, switch (state.processingState) {
ProcessingState.loading => AudioProcessingState.loading, ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.buffering => AudioProcessingState.buffering, ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.ready => AudioProcessingState.ready, ProcessingState.buffering => AudioProcessingState.buffering,
ProcessingState.completed => AudioProcessingState.completed, ProcessingState.ready => AudioProcessingState.ready,
}; ProcessingState.completed => AudioProcessingState.completed,
};
final bool isPlaying = state.playing && state.processingState == ProcessingState.ready; final bool isPlaying =
final bool isBuffering = (state.playing && state.processingState != ProcessingState.ready) || state.playing && state.processingState == ProcessingState.ready;
(_playRequested && !state.playing && state.processingState != ProcessingState.completed); final bool isBuffering =
(state.playing && state.processingState != ProcessingState.ready) ||
(_playRequested &&
!state.playing &&
state.processingState != ProcessingState.completed);
playbackState.add( playbackState.add(
playbackState.value.copyWith( playbackState.value.copyWith(
controls: <MediaControl>[ controls: <MediaControl>[
if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play, if (isPlaying || isBuffering)
MediaControl.stop
else
MediaControl.play,
], ],
systemActions: const <MediaAction>{}, systemActions: const <MediaAction>{},
processingState: isBuffering ? AudioProcessingState.buffering : processingState, processingState: isBuffering
? AudioProcessingState.buffering
: processingState,
playing: isPlaying, playing: isPlaying,
), ),
); );
+6 -2
View File
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:timezone/data/latest.dart' as tz; import 'package:timezone/data/latest.dart' as tz;
import 'app_theme_business_object.dart'; import 'app_theme_business_object.dart';
import 'carplay_bridge.dart';
import 'casting_business_object.dart'; import 'casting_business_object.dart';
import 'live_info_business_object.dart'; import 'live_info_business_object.dart';
import 'live_info_panel.dart'; import 'live_info_panel.dart';
@@ -35,16 +36,20 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> { class _MyAppState extends State<MyApp> {
final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject(); final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject();
final CastingBusinessObject _casting = CastingBusinessObject(); final CastingBusinessObject _casting = CastingBusinessObject();
late final CarPlayBridge _carPlayBridge;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_carPlayBridge = CarPlayBridge(audio: _audio);
unawaited(_audio.ensureInitialized()); unawaited(_audio.ensureInitialized());
unawaited(_carPlayBridge.start());
_casting.initialize(); _casting.initialize();
} }
@override @override
void dispose() { void dispose() {
unawaited(_carPlayBridge.dispose());
_casting.dispose(); _casting.dispose();
_audio.dispose(); _audio.dispose();
super.dispose(); super.dispose();
@@ -457,8 +462,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
label: const Text('Stop'), label: const Text('Stop'),
), ),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: onPressed: _casting.status == CastingStatus.connecting
_casting.status == CastingStatus.connecting
? null ? null
: _toggleCasting, : _toggleCasting,
icon: Icon(_castingIcon()), icon: Icon(_castingIcon()),