Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c523d0121f | ||
|
|
b8e86263d9 | ||
|
|
e3288e044a | ||
|
|
b8d624343b |
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"flutter": true
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,46 @@ import UIKit
|
||||
* 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.
|
||||
*/
|
||||
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 listTemplate: CPListTemplate?
|
||||
private var streamItem: CPListItem?
|
||||
private var snapshot: Snapshot = .default
|
||||
|
||||
// MARK: - CPTemplateApplicationSceneDelegate
|
||||
|
||||
@@ -25,14 +61,36 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||
didConnect interfaceController: CPInterfaceController
|
||||
) {
|
||||
appDelegate?.ensureFlutterBridgeReadyForCarPlay()
|
||||
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(
|
||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||
didDisconnectInterfaceController interfaceController: CPInterfaceController
|
||||
) {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: AppDelegate.carPlaySnapshotDidChangeNotification,
|
||||
object: nil
|
||||
)
|
||||
carPlayInterfaceController = nil
|
||||
listTemplate = nil
|
||||
appDelegate?.invokeStop()
|
||||
@@ -41,40 +99,105 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
||||
// MARK: - Template construction
|
||||
|
||||
private func makeListTemplate() -> CPListTemplate {
|
||||
let artwork = CPListItem.maximumImageSize
|
||||
let icon = UIImage(named: "AppIcon")?
|
||||
.withRenderingMode(.alwaysOriginal)
|
||||
|
||||
let item = CPListItem(
|
||||
text: "KRYZ Live Stream",
|
||||
detailText: "KRYZ Radio",
|
||||
text: snapshot.title,
|
||||
detailText: detailText(for: snapshot),
|
||||
image: icon
|
||||
)
|
||||
item.handler = { [weak self] _, completion in
|
||||
self?.handleStreamItemTapped()
|
||||
completion()
|
||||
streamItem = item
|
||||
|
||||
if #available(iOS 14.0, *) {
|
||||
item.handler = { [weak self] _, completion in
|
||||
self?.handleStreamItemTapped()
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
let section = CPListSection(items: [item])
|
||||
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
|
||||
// Silence unused-variable warning
|
||||
_ = artwork
|
||||
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()
|
||||
carPlayInterfaceController?.pushTemplate(
|
||||
CPNowPlayingTemplate.shared,
|
||||
animated: true,
|
||||
completion: nil
|
||||
)
|
||||
appDelegate?.requestCarPlaySnapshot()
|
||||
guard let interfaceController = carPlayInterfaceController else {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
@@ -82,4 +205,20 @@ class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
||||
private var appDelegate: 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -18,7 +18,8 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
|
||||
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a';
|
||||
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 _metadataRefreshInterval = Duration(seconds: 20);
|
||||
|
||||
@@ -27,10 +28,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
title: 'KRYZ Live Stream',
|
||||
artist: 'KRYZ Radio',
|
||||
album: 'Live',
|
||||
extras: <String, dynamic>{
|
||||
'isLive': true,
|
||||
'seekable': false,
|
||||
},
|
||||
extras: <String, dynamic>{'isLive': true, 'seekable': false},
|
||||
);
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
@@ -82,10 +80,7 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
try {
|
||||
if (!_isSourceLoaded) {
|
||||
await _player.setAudioSource(
|
||||
AudioSource.uri(
|
||||
Uri.parse(streamUrl),
|
||||
tag: mediaItem.value,
|
||||
),
|
||||
AudioSource.uri(Uri.parse(streamUrl), tag: mediaItem.value),
|
||||
);
|
||||
_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
|
||||
Future<void> seek(Duration position) async {
|
||||
// Live stream is intentionally non-seekable.
|
||||
@@ -158,25 +160,35 @@ class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
}
|
||||
|
||||
void _onPlayerStateChanged(PlayerState state) {
|
||||
final AudioProcessingState processingState = switch (state.processingState) {
|
||||
ProcessingState.idle => AudioProcessingState.idle,
|
||||
ProcessingState.loading => AudioProcessingState.loading,
|
||||
ProcessingState.buffering => AudioProcessingState.buffering,
|
||||
ProcessingState.ready => AudioProcessingState.ready,
|
||||
ProcessingState.completed => AudioProcessingState.completed,
|
||||
};
|
||||
final AudioProcessingState processingState =
|
||||
switch (state.processingState) {
|
||||
ProcessingState.idle => AudioProcessingState.idle,
|
||||
ProcessingState.loading => AudioProcessingState.loading,
|
||||
ProcessingState.buffering => AudioProcessingState.buffering,
|
||||
ProcessingState.ready => AudioProcessingState.ready,
|
||||
ProcessingState.completed => AudioProcessingState.completed,
|
||||
};
|
||||
|
||||
final bool isPlaying = state.playing && state.processingState == ProcessingState.ready;
|
||||
final bool isBuffering = (state.playing && state.processingState != ProcessingState.ready) ||
|
||||
(_playRequested && !state.playing && state.processingState != ProcessingState.completed);
|
||||
final bool isPlaying =
|
||||
state.playing && state.processingState == ProcessingState.ready;
|
||||
final bool isBuffering =
|
||||
(state.playing && state.processingState != ProcessingState.ready) ||
|
||||
(_playRequested &&
|
||||
!state.playing &&
|
||||
state.processingState != ProcessingState.completed);
|
||||
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
controls: <MediaControl>[
|
||||
if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play,
|
||||
if (isPlaying || isBuffering)
|
||||
MediaControl.stop
|
||||
else
|
||||
MediaControl.play,
|
||||
],
|
||||
systemActions: const <MediaAction>{},
|
||||
processingState: isBuffering ? AudioProcessingState.buffering : processingState,
|
||||
processingState: isBuffering
|
||||
? AudioProcessingState.buffering
|
||||
: processingState,
|
||||
playing: isPlaying,
|
||||
),
|
||||
);
|
||||
|
||||
+6
-2
@@ -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<MyApp> {
|
||||
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();
|
||||
@@ -457,8 +462,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
|
||||
label: const Text('Stop'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed:
|
||||
_casting.status == CastingStatus.connecting
|
||||
onPressed: _casting.status == CastingStatus.connecting
|
||||
? null
|
||||
: _toggleCasting,
|
||||
icon: Icon(_castingIcon()),
|
||||
|
||||
Reference in New Issue
Block a user