From c9ef69c8b5e13122d678f780732f08b96bfa46cc Mon Sep 17 00:00:00 2001 From: kfj001 Date: Thu, 14 May 2026 22:02:37 -0700 Subject: [PATCH 1/3] Adds Apple airplay/Google cast support to the app --- android/app/build.gradle.kts | 6 + android/app/src/main/AndroidManifest.xml | 4 + .../kryz_go_flutter/CastOptionsProvider.kt | 24 ++ .../example/kryz_go_flutter/MainActivity.kt | 405 +++++++++++++++++- .../app/src/main/res/values-night/styles.xml | 5 +- android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/styles.xml | 5 +- ios/Runner/AppDelegate.swift | 139 +++++- lib/casting_business_object.dart | 211 +++++++++ lib/main.dart | 193 ++++++++- 10 files changed, 979 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/kotlin/com/example/kryz_go_flutter/CastOptionsProvider.kt create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 lib/casting_business_object.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 70ac789..ad02b52 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -5,6 +5,12 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +dependencies { + implementation("com.google.android.gms:play-services-cast-framework:22.1.0") + implementation("androidx.mediarouter:mediarouter:1.7.0") + implementation("androidx.appcompat:appcompat:1.7.0") +} + android { namespace = "com.example.kryz_go_flutter" compileSdk = flutter.compileSdkVersion diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4d589ee..a3f7be8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + + ? { + return null + } +} diff --git a/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt b/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt index 4186888..9b80c66 100644 --- a/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/kryz_go_flutter/MainActivity.kt @@ -1,5 +1,408 @@ package com.example.kryz_go_flutter +import android.os.Handler +import android.os.Looper +import androidx.appcompat.view.ContextThemeWrapper +import androidx.mediarouter.app.MediaRouteChooserDialog +import androidx.mediarouter.media.MediaRouteSelector +import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.MediaLoadRequestData +import com.google.android.gms.cast.MediaMetadata +import com.google.android.gms.cast.MediaStatus +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.SessionManagerListener +import com.google.android.gms.cast.framework.media.RemoteMediaClient +import com.google.android.gms.common.api.CommonStatusCodes import com.ryanheise.audioservice.AudioServiceActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.json.JSONObject -class MainActivity : AudioServiceActivity() +class MainActivity : AudioServiceActivity(), MethodChannel.MethodCallHandler, EventChannel.StreamHandler { + companion object { + private const val METHOD_CHANNEL = "kryz_go_flutter/casting/methods" + private const val EVENT_CHANNEL = "kryz_go_flutter/casting/events" + } + + private var castContext: CastContext? = null + private var eventSink: EventChannel.EventSink? = null + private var chooserDialog: MediaRouteChooserDialog? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var activeRemoteMediaClient: RemoteMediaClient? = null + + private var statusPayload: MutableMap = mutableMapOf( + "status" to "idle", + "playbackStatus" to "stopped", + "platform" to "android", + ) + + private val remoteMediaClientCallback = object : RemoteMediaClient.Callback() { + override fun onStatusUpdated() { + emitRemotePlaybackStatus() + } + + override fun onMetadataUpdated() { + emitRemotePlaybackStatus() + } + } + + private val castSessionManagerListener = object : SessionManagerListener { + override fun onSessionStarting(session: CastSession) { + emitStatus("connecting") + } + + override fun onSessionStarted(session: CastSession, sessionId: String) { + attachRemoteClientCallback(session) + emitStatus("connected", session.castDevice?.friendlyName) + } + + override fun onSessionStartFailed(session: CastSession, error: Int) { + emitStatus("error", message = "Cast session failed to start ($error)") + } + + override fun onSessionEnding(session: CastSession) { + detachRemoteClientCallback() + // The Cast framework reports the final state in onSessionEnded. + } + + override fun onSessionEnded(session: CastSession, error: Int) { + emitStatus("idle") + } + + override fun onSessionResuming(session: CastSession, sessionId: String) { + emitStatus("connecting") + } + + override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { + attachRemoteClientCallback(session) + emitStatus("connected", session.castDevice?.friendlyName) + } + + override fun onSessionResumeFailed(session: CastSession, error: Int) { + emitStatus("error", message = "Cast session failed to resume ($error)") + } + + override fun onSessionSuspended(session: CastSession, reason: Int) { + detachRemoteClientCallback() + emitStatus("idle") + } + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL) + .setMethodCallHandler(this) + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL) + .setStreamHandler(this) + + initializeCastContext() + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getStatus" -> result.success(statusPayload) + "showDevicePicker" -> { + showDevicePicker() + result.success(null) + } + "startCastingLiveStream" -> { + startCastingLiveStream(call, result) + } + "disconnect" -> { + castContext?.sessionManager?.endCurrentSession(true) + emitStatus("idle") + result.success(null) + } + "stopCastingPlayback" -> { + stopCastingPlayback(result) + } + else -> result.notImplemented() + } + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + events.success(statusPayload) + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + override fun onDestroy() { + chooserDialog?.dismiss() + chooserDialog = null + + castContext?.sessionManager?.removeSessionManagerListener( + castSessionManagerListener, + CastSession::class.java, + ) + detachRemoteClientCallback() + + super.onDestroy() + } + + private fun initializeCastContext() { + try { + castContext = CastContext.getSharedInstance(this) + castContext?.sessionManager?.addSessionManagerListener( + castSessionManagerListener, + CastSession::class.java, + ) + + val currentSession = castContext?.sessionManager?.currentCastSession + if (currentSession != null && currentSession.isConnected) { + attachRemoteClientCallback(currentSession) + emitStatus("connected", currentSession.castDevice?.friendlyName) + } else { + emitStatus("idle") + } + } catch (error: Exception) { + emitStatus("unavailable", message = error.message ?: "Cast unavailable") + } + } + + private fun showDevicePicker() { + if (castContext == null) { + emitStatus("unavailable", message = "Cast services unavailable") + return + } + + val receiverAppId = getString(R.string.cast_receiver_app_id) + val routeSelector = MediaRouteSelector.Builder() + .addControlCategory(CastMediaControlIntent.categoryForCast(receiverAppId)) + .build() + + try { + val themedContext = ContextThemeWrapper(this, androidx.appcompat.R.style.Theme_AppCompat_DayNight) + + chooserDialog?.dismiss() + chooserDialog = MediaRouteChooserDialog(themedContext) + chooserDialog?.routeSelector = routeSelector + chooserDialog?.setCanceledOnTouchOutside(true) + chooserDialog?.show() + + emitStatus("connecting") + } catch (error: IllegalArgumentException) { + emitStatus("error", message = "Unable to open cast picker: ${error.message}") + } + } + + private fun startCastingLiveStream(call: MethodCall, result: MethodChannel.Result) { + val arguments = call.arguments as? Map<*, *> + if (arguments == null) { + result.error("invalid_arguments", "Missing cast media payload", null) + return + } + + val streamUrl = arguments["streamUrl"] as? String + val title = arguments["title"] as? String ?: "Live Stream" + val subtitle = arguments["subtitle"] as? String ?: "" + + if (streamUrl.isNullOrBlank()) { + result.error("invalid_stream_url", "streamUrl is required", null) + return + } + + val currentSession = castContext?.sessionManager?.currentCastSession + if (currentSession == null || !currentSession.isConnected) { + result.error("no_cast_session", "Connect to a cast device first", null) + return + } + + val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply { + putString(MediaMetadata.KEY_TITLE, title) + putString(MediaMetadata.KEY_SUBTITLE, subtitle) + } + + val mediaInfo = MediaInfo.Builder(streamUrl) + .setStreamType(MediaInfo.STREAM_TYPE_LIVE) + .setContentType("audio/mpeg") + .setMetadata(metadata) + .build() + + val loadRequestData = MediaLoadRequestData.Builder() + .setMediaInfo(mediaInfo) + .setAutoplay(true) + .build() + + loadMediaWithRetry( + session = currentSession, + loadRequestData = loadRequestData, + attempt = 1, + result = result, + ) + } + + private fun loadMediaWithRetry( + session: CastSession, + loadRequestData: MediaLoadRequestData, + attempt: Int, + result: MethodChannel.Result, + ) { + val remoteMediaClient = session.remoteMediaClient + if (remoteMediaClient == null) { + if (attempt >= 8) { + val message = "Cast client not ready after session connection" + emitStatus("error", message = message) + result.error("no_remote_media_client", message, null) + return + } + + emitStatus("connecting", session.castDevice?.friendlyName, playbackStatus = "buffering") + mainHandler.postDelayed( + { + loadMediaWithRetry( + session = session, + loadRequestData = loadRequestData, + attempt = attempt + 1, + result = result, + ) + }, + 350, + ) + return + } + + try { + remoteMediaClient.load(loadRequestData).setResultCallback { mediaChannelResult -> + if (mediaChannelResult == null) { + emitStatus("error", message = "Cast load returned no result") + result.error("cast_load_failed", "Cast load returned no result", null) + return@setResultCallback + } + + if (mediaChannelResult.status.isSuccess) { + emitStatus("connected", session.castDevice?.friendlyName) + emitRemotePlaybackStatus() + result.success(null) + return@setResultCallback + } + + val statusCode = mediaChannelResult.status.statusCode + val statusText = CommonStatusCodes.getStatusCodeString(statusCode) + val message = "Cast load failed: $statusText ($statusCode)" + emitStatus("error", message = message) + result.error("cast_load_failed", message, null) + } + } catch (error: Exception) { + result.error("cast_load_failed", error.message, JSONObject.NULL) + } + } + + private fun stopCastingPlayback(result: MethodChannel.Result) { + val session = castContext?.sessionManager?.currentCastSession + if (session == null || !session.isConnected) { + result.error("no_cast_session", "Connect to a cast device first", null) + return + } + + val remoteMediaClient = session.remoteMediaClient + if (remoteMediaClient == null) { + result.error("no_remote_media_client", "Unable to control cast media", null) + return + } + + try { + remoteMediaClient.stop().setResultCallback { stopResult -> + if (stopResult == null) { + result.error("cast_stop_failed", "Cast stop returned no result", null) + return@setResultCallback + } + + if (stopResult.status.isSuccess) { + emitStatus("connected", session.castDevice?.friendlyName, playbackStatus = "stopped") + result.success(null) + return@setResultCallback + } + + val code = stopResult.status.statusCode + val statusText = CommonStatusCodes.getStatusCodeString(code) + result.error("cast_stop_failed", "Cast stop failed: $statusText ($code)", null) + } + } catch (error: Exception) { + result.error("cast_stop_failed", error.message, null) + } + } + + private fun attachRemoteClientCallback(session: CastSession) { + val remoteMediaClient = session.remoteMediaClient + if (remoteMediaClient == null) { + detachRemoteClientCallback() + return + } + + if (activeRemoteMediaClient === remoteMediaClient) { + emitRemotePlaybackStatus() + return + } + + detachRemoteClientCallback() + activeRemoteMediaClient = remoteMediaClient + remoteMediaClient.registerCallback(remoteMediaClientCallback) + emitRemotePlaybackStatus() + } + + private fun detachRemoteClientCallback() { + activeRemoteMediaClient?.unregisterCallback(remoteMediaClientCallback) + activeRemoteMediaClient = null + } + + private fun emitRemotePlaybackStatus() { + val remoteMediaClient = activeRemoteMediaClient + val mediaStatus = remoteMediaClient?.mediaStatus + val playbackStatus = when (mediaStatus?.playerState) { + MediaStatus.PLAYER_STATE_PLAYING -> "playing" + MediaStatus.PLAYER_STATE_BUFFERING, + MediaStatus.PLAYER_STATE_LOADING -> "buffering" + MediaStatus.PLAYER_STATE_PAUSED, + MediaStatus.PLAYER_STATE_IDLE, + MediaStatus.PLAYER_STATE_UNKNOWN, + null -> "stopped" + else -> "stopped" + } + + val currentSession = castContext?.sessionManager?.currentCastSession + if (currentSession != null && currentSession.isConnected) { + emitStatus("connected", currentSession.castDevice?.friendlyName, playbackStatus = playbackStatus) + } + } + + private fun emitStatus( + status: String, + deviceName: String? = null, + message: String? = null, + playbackStatus: String? = null, + ) { + val resolvedPlaybackStatus = playbackStatus + ?: statusPayload["playbackStatus"] as? String + ?: "stopped" + + if (status == "idle" || status == "unavailable") { + statusPayload["playbackStatus"] = "stopped" + } + + val payload = mutableMapOf( + "status" to status, + "playbackStatus" to if (status == "idle" || status == "unavailable") "stopped" else resolvedPlaybackStatus, + "platform" to "android", + ) + + if (!deviceName.isNullOrBlank()) { + payload["deviceName"] = deviceName + } + + if (!message.isNullOrBlank()) { + payload["message"] = message + } + + statusPayload = payload + eventSink?.success(payload) + } +} diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..8d48633 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -12,7 +12,8 @@ running. This Theme is only used starting with V2 of Flutter's Android embedding. --> - diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c820047 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + CC1AD845 + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..b025cbf 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -12,7 +12,8 @@ running. This Theme is only used starting with V2 of Flutter's Android embedding. --> - diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index be0ea92..4bf13d6 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,9 +1,19 @@ import Flutter import UIKit import AVFoundation +import AVKit @main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler { + private let castingMethodChannelName = "kryz_go_flutter/casting/methods" + private let castingEventChannelName = "kryz_go_flutter/casting/events" + + private var castingEventSink: FlutterEventSink? + private var castingStatus: [String: Any] = [ + "status": "idle", + "platform": "ios" + ] + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -17,10 +27,137 @@ import AVFoundation NSLog("Failed to configure AVAudioSession: \(error)") } + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioRouteChange), + name: AVAudioSession.routeChangeNotification, + object: nil + ) + + updateCastingStatusForCurrentRoute() + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + + guard let registrar = engineBridge.pluginRegistry.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) + } + } + + @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 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) } } diff --git a/lib/casting_business_object.dart b/lib/casting_business_object.dart new file mode 100644 index 0000000..b8a1a87 --- /dev/null +++ b/lib/casting_business_object.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +enum CastingStatus { unavailable, idle, connecting, connected, error } + +enum CastingPlaybackStatus { unknown, stopped, buffering, playing } + +class CastingBusinessObject extends ChangeNotifier { + static const MethodChannel _methodChannel = MethodChannel( + 'kryz_go_flutter/casting/methods', + ); + static const EventChannel _eventChannel = EventChannel( + 'kryz_go_flutter/casting/events', + ); + + StreamSubscription? _eventSubscription; + CastingStatus _status = CastingStatus.unavailable; + CastingPlaybackStatus _playbackStatus = CastingPlaybackStatus.unknown; + String? _connectedDeviceName; + String? _lastError; + + CastingStatus get status => _status; + CastingPlaybackStatus get playbackStatus => _playbackStatus; + String? get connectedDeviceName => _connectedDeviceName; + String? get lastError => _lastError; + + bool get isSupportedOnThisPlatform { + if (kIsWeb) { + return false; + } + + return defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android; + } + + bool get isConnected => _status == CastingStatus.connected; + bool get isPlaying => _playbackStatus == CastingPlaybackStatus.playing; + bool get isBuffering => _playbackStatus == CastingPlaybackStatus.buffering; + bool get isStopped => _playbackStatus == CastingPlaybackStatus.stopped; + + Future initialize() async { + if (!isSupportedOnThisPlatform) { + _setStatus(CastingStatus.unavailable); + return; + } + + _eventSubscription?.cancel(); + _eventSubscription = _eventChannel.receiveBroadcastStream().listen( + _handleEvent, + onError: (Object error) { + _lastError = error.toString(); + _setStatus(CastingStatus.error); + }, + ); + + try { + final dynamic status = await _methodChannel.invokeMethod( + 'getStatus', + ); + _handleEvent(status); + } on PlatformException catch (error) { + _lastError = error.message ?? error.code; + _setStatus(CastingStatus.error); + } + } + + Future showDevicePicker() async { + if (!isSupportedOnThisPlatform) { + _setStatus(CastingStatus.unavailable); + return; + } + + _setStatus(CastingStatus.connecting); + + try { + await _methodChannel.invokeMethod('showDevicePicker'); + } on PlatformException catch (error) { + _lastError = error.message ?? error.code; + _setStatus(CastingStatus.error); + } + } + + Future startCastingLiveStream({ + required String streamUrl, + required String title, + required String subtitle, + }) async { + if (!isSupportedOnThisPlatform) { + _setStatus(CastingStatus.unavailable); + return; + } + + try { + await _methodChannel.invokeMethod('startCastingLiveStream', { + 'streamUrl': streamUrl, + 'title': title, + 'subtitle': subtitle, + }); + } on PlatformException catch (error) { + _lastError = error.message ?? error.code; + _setStatus(CastingStatus.error); + } + } + + Future disconnect() async { + if (!isSupportedOnThisPlatform) { + _setStatus(CastingStatus.unavailable); + return; + } + + try { + await _methodChannel.invokeMethod('disconnect'); + } on PlatformException catch (error) { + _lastError = error.message ?? error.code; + _setStatus(CastingStatus.error); + } + } + + Future stopPlayback() async { + if (!isSupportedOnThisPlatform) { + _setStatus(CastingStatus.unavailable); + return; + } + + try { + await _methodChannel.invokeMethod('stopCastingPlayback'); + } on PlatformException catch (error) { + _lastError = error.message ?? error.code; + _setStatus(CastingStatus.error); + } + } + + void _handleEvent(dynamic event) { + if (event is! Map) { + return; + } + + final String? status = event['status'] as String?; + final String? playbackStatus = event['playbackStatus'] as String?; + final String? deviceName = event['deviceName'] as String?; + final String? message = event['message'] as String?; + + if (deviceName != null && deviceName.isNotEmpty) { + _connectedDeviceName = deviceName; + } + + if (message != null && message.isNotEmpty) { + _lastError = message; + } + + _setPlaybackStatus(_parsePlaybackStatus(playbackStatus)); + + switch (status) { + case 'idle': + _setStatus(CastingStatus.idle); + break; + case 'connecting': + _setStatus(CastingStatus.connecting); + break; + case 'connected': + _setStatus(CastingStatus.connected); + break; + case 'error': + _setStatus(CastingStatus.error); + break; + case 'unavailable': + default: + _setStatus(CastingStatus.unavailable); + break; + } + } + + void _setStatus(CastingStatus status) { + if (_status == status) { + return; + } + + _status = status; + notifyListeners(); + } + + CastingPlaybackStatus _parsePlaybackStatus(String? playbackStatus) { + switch (playbackStatus) { + case 'playing': + return CastingPlaybackStatus.playing; + case 'buffering': + return CastingPlaybackStatus.buffering; + case 'stopped': + return CastingPlaybackStatus.stopped; + default: + return CastingPlaybackStatus.unknown; + } + } + + void _setPlaybackStatus(CastingPlaybackStatus status) { + if (_playbackStatus == status) { + return; + } + + _playbackStatus = status; + notifyListeners(); + } + + @override + void dispose() { + _eventSubscription?.cancel(); + super.dispose(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 2a3a068..16bb076 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:just_audio_background/just_audio_background.dart'; import 'package:flutter/services.dart'; import 'app_theme_business_object.dart'; +import 'casting_business_object.dart'; import 'live_info_business_object.dart'; import 'live_info_panel.dart'; import 'streaming_audio_business_object.dart'; @@ -32,9 +34,17 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject(); + final CastingBusinessObject _casting = CastingBusinessObject(); + + @override + void initState() { + super.initState(); + _casting.initialize(); + } @override void dispose() { + _casting.dispose(); _audio.dispose(); super.dispose(); } @@ -46,16 +56,21 @@ class _MyAppState extends State { theme: AppThemeBusinessObject.lightTheme(), darkTheme: AppThemeBusinessObject.darkTheme(), themeMode: ThemeMode.system, - home: MainPage(audio: _audio), + home: MainPage(audio: _audio, casting: _casting), debugShowCheckedModeBanner: false, ); } } class MainPage extends StatefulWidget { - const MainPage({super.key, required this.audio}); + const MainPage({ + super.key, + required this.audio, + required this.casting, + }); final StreamingAudioBusinessObject audio; + final CastingBusinessObject casting; @override State createState() => _MainPageState(); @@ -63,24 +78,61 @@ class MainPage extends StatefulWidget { class _MainPageState extends State { StreamingAudioBusinessObject get _audio => widget.audio; + CastingBusinessObject get _casting => widget.casting; final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject(); static const List _intervalOptions = [5, 10, 15, 30, 60]; + bool _castStreamStartedForActiveSession = false; + bool _isStartingCastStream = false; + bool _pendingRemotePlay = false; + + bool get _usesExternalCastPlayback => + _casting.isConnected && defaultTargetPlatform == TargetPlatform.android; + + bool get _isIosAirPlay => defaultTargetPlatform == TargetPlatform.iOS; + + bool get _isUsingRemotePlayback => _usesExternalCastPlayback; + + bool get _isPlaybackStopped { + if (_isUsingRemotePlayback) { + return _casting.isStopped || _casting.playbackStatus == CastingPlaybackStatus.unknown; + } + + return _audio.isStopped; + } + + bool get _isPlaybackPlaying { + if (_isUsingRemotePlayback) { + return _casting.isPlaying; + } + + return _audio.isPlaying; + } + + bool get _isPlaybackBuffering { + if (_isUsingRemotePlayback) { + return _casting.isBuffering; + } + + return _audio.isBuffering; + } String _playbackStatusLabel() { - switch (_audio.playbackStatus) { - case StreamingPlaybackStatus.stopped: - return 'Stopped'; - case StreamingPlaybackStatus.buffering: - return 'Buffering...'; - case StreamingPlaybackStatus.playing: - return 'Playing'; + if (_isPlaybackBuffering) { + return 'Buffering...'; } + + if (_isPlaybackPlaying) { + return 'Playing'; + } + + return 'Stopped'; } @override void initState() { super.initState(); _audio.addListener(_handleAudioStateChanged); + _casting.addListener(_handleCastingStateChanged); _liveInfo.addListener(_handleLiveInfoChanged); _liveInfo.start(); } @@ -105,17 +157,127 @@ class _MainPageState extends State { }); } + void _handleCastingStateChanged() { + if ( + _usesExternalCastPlayback && + (!_castStreamStartedForActiveSession || _pendingRemotePlay) && + !_isStartingCastStream) { + _startCastingLiveStream(); + } + + if (!_usesExternalCastPlayback) { + _castStreamStartedForActiveSession = false; + _isStartingCastStream = false; + _pendingRemotePlay = false; + } + + if (!mounted) { + return; + } + + setState(() { + // Rebuild when cast state changes from the native platforms. + }); + } + + Future _startCastingLiveStream() async { + _isStartingCastStream = true; + + try { + await _casting.startCastingLiveStream( + streamUrl: _audio.streamUrl, + title: 'KRYZ Live Stream', + subtitle: 'KRYZ Radio', + ); + + _castStreamStartedForActiveSession = true; + _pendingRemotePlay = false; + + if (_usesExternalCastPlayback && !_audio.isStopped) { + await _audio.stop(); + } + } catch (_) { + _castStreamStartedForActiveSession = false; + // State is already updated by the casting business object. + } finally { + _isStartingCastStream = false; + } + } + Future _play() async { + if (_usesExternalCastPlayback) { + if (_casting.isPlaying || _isStartingCastStream) { + return; + } + + await _startCastingLiveStream(); + return; + } + + if (_usesExternalCastPlayback && _casting.status == CastingStatus.connecting) { + _pendingRemotePlay = true; + return; + } + await _audio.play(); } Future _stop() async { + if (_usesExternalCastPlayback) { + await _casting.stopPlayback(); + return; + } + await _audio.stop(); } + Future _toggleCasting() async { + if (_casting.isConnected) { + await _casting.disconnect(); + return; + } + + await _casting.showDevicePicker(); + } + + String _castingLabel() { + final idleLabel = _isIosAirPlay ? 'AirPlay' : 'Cast'; + final connectingLabel = _isIosAirPlay ? 'AirPlay...' : 'Casting...'; + final errorLabel = _isIosAirPlay ? 'AirPlay Error' : 'Cast Error'; + final unavailableLabel = _isIosAirPlay ? 'No AirPlay' : 'No Cast'; + + switch (_casting.status) { + case CastingStatus.idle: + return idleLabel; + case CastingStatus.connecting: + return connectingLabel; + case CastingStatus.connected: + final deviceName = _casting.connectedDeviceName; + if (deviceName != null && deviceName.isNotEmpty) { + return 'On $deviceName'; + } + return _isIosAirPlay ? 'AirPlaying' : 'Casting'; + case CastingStatus.error: + return errorLabel; + case CastingStatus.unavailable: + return unavailableLabel; + } + } + + IconData _castingIcon() { + if (_isIosAirPlay) { + return Icons.airplay_rounded; + } + + return _casting.isConnected + ? Icons.cast_connected_rounded + : Icons.cast_rounded; + } + @override void dispose() { _audio.removeListener(_handleAudioStateChanged); + _casting.removeListener(_handleCastingStateChanged); _liveInfo.removeListener(_handleLiveInfoChanged); _liveInfo.dispose(); super.dispose(); @@ -225,15 +387,24 @@ class _MainPageState extends State { runSpacing: 12, children: [ ElevatedButton.icon( - onPressed: _audio.isStopped ? _play : null, + onPressed: _isPlaybackStopped ? _play : null, icon: const Icon(Icons.play_arrow_rounded), label: const Text('Play'), ), ElevatedButton.icon( - onPressed: _audio.isStopped ? null : _stop, + onPressed: _isPlaybackStopped ? null : _stop, icon: const Icon(Icons.stop_rounded), label: const Text('Stop'), ), + ElevatedButton.icon( + onPressed: + _casting.status == CastingStatus.connecting || + _casting.status == CastingStatus.unavailable + ? null + : _toggleCasting, + icon: Icon(_castingIcon()), + label: Text(_castingLabel()), + ), Text( _playbackStatusLabel(), style: theme.textTheme.titleSmall?.copyWith( -- 2.54.0 From f7979a70e6c7b4054f763305309bfff8987971c5 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Thu, 14 May 2026 22:02:50 -0700 Subject: [PATCH 2/3] additional changes --- lib/main.dart | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 16bb076..f35b924 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -63,11 +63,7 @@ class _MyAppState extends State { } class MainPage extends StatefulWidget { - const MainPage({ - super.key, - required this.audio, - required this.casting, - }); + const MainPage({super.key, required this.audio, required this.casting}); final StreamingAudioBusinessObject audio; final CastingBusinessObject casting; @@ -94,7 +90,8 @@ class _MainPageState extends State { bool get _isPlaybackStopped { if (_isUsingRemotePlayback) { - return _casting.isStopped || _casting.playbackStatus == CastingPlaybackStatus.unknown; + return _casting.isStopped || + _casting.playbackStatus == CastingPlaybackStatus.unknown; } return _audio.isStopped; @@ -158,8 +155,7 @@ class _MainPageState extends State { } void _handleCastingStateChanged() { - if ( - _usesExternalCastPlayback && + if (_usesExternalCastPlayback && (!_castStreamStartedForActiveSession || _pendingRemotePlay) && !_isStartingCastStream) { _startCastingLiveStream(); @@ -214,7 +210,8 @@ class _MainPageState extends State { return; } - if (_usesExternalCastPlayback && _casting.status == CastingStatus.connecting) { + if (_usesExternalCastPlayback && + _casting.status == CastingStatus.connecting) { _pendingRemotePlay = true; return; } -- 2.54.0 From 283a918b815ba910a241c434f86308d0ef73be9e Mon Sep 17 00:00:00 2001 From: kfj001 Date: Thu, 14 May 2026 22:17:32 -0700 Subject: [PATCH 3/3] corrects minor branding issue with cast/airplay --- lib/casting_business_object.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/casting_business_object.dart b/lib/casting_business_object.dart index b8a1a87..c3c1dd7 100644 --- a/lib/casting_business_object.dart +++ b/lib/casting_business_object.dart @@ -72,7 +72,9 @@ class CastingBusinessObject extends ChangeNotifier { return; } - _setStatus(CastingStatus.connecting); + if (defaultTargetPlatform == TargetPlatform.android) { + _setStatus(CastingStatus.connecting); + } try { await _methodChannel.invokeMethod('showDevicePicker'); -- 2.54.0