Merge pull request 'Adds Apple airplay/Google cast support to the app' (#2) from cast into main

Reviewed-on: http://192.168.1.74:30008/kfj001/kryz-go-flutter/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
2026-05-14 22:19:45 -07:00
10 changed files with 978 additions and 17 deletions
+6
View File
@@ -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
+4
View File
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<application
android:label="KRYZ Go!"
@@ -37,6 +38,9 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.example.kryz_go_flutter.CastOptionsProvider" />
<service
android:name="com.ryanheise.audioservice.AudioService"
@@ -0,0 +1,24 @@
package com.example.kryz_go_flutter
import android.content.Context
import com.google.android.gms.cast.LaunchOptions
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
return CastOptions.Builder()
.setReceiverApplicationId(context.getString(R.string.cast_receiver_app_id))
.setLaunchOptions(
LaunchOptions.Builder()
.setAndroidReceiverCompatible(true)
.build(),
)
.build()
}
override fun getAdditionalSessionProviders(context: Context): MutableList<SessionProvider>? {
return null
}
}
@@ -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<String, Any?> = 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<CastSession> {
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<String, Any?>(
"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)
}
}
@@ -12,7 +12,8 @@
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<style name="NormalTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:windowIsTranslucent">false</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cast_receiver_app_id">CC1AD845</string>
</resources>
+3 -2
View File
@@ -12,7 +12,8 @@
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<style name="NormalTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowBackground">@android:color/white</item>
<item name="android:windowIsTranslucent">false</item>
</style>
</resources>
+138 -1
View File
@@ -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)
}
}
+213
View File
@@ -0,0 +1,213 @@
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<dynamic>? _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<void> 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<dynamic>(
'getStatus',
);
_handleEvent(status);
} on PlatformException catch (error) {
_lastError = error.message ?? error.code;
_setStatus(CastingStatus.error);
}
}
Future<void> showDevicePicker() async {
if (!isSupportedOnThisPlatform) {
_setStatus(CastingStatus.unavailable);
return;
}
if (defaultTargetPlatform == TargetPlatform.android) {
_setStatus(CastingStatus.connecting);
}
try {
await _methodChannel.invokeMethod<void>('showDevicePicker');
} on PlatformException catch (error) {
_lastError = error.message ?? error.code;
_setStatus(CastingStatus.error);
}
}
Future<void> startCastingLiveStream({
required String streamUrl,
required String title,
required String subtitle,
}) async {
if (!isSupportedOnThisPlatform) {
_setStatus(CastingStatus.unavailable);
return;
}
try {
await _methodChannel.invokeMethod<void>('startCastingLiveStream', {
'streamUrl': streamUrl,
'title': title,
'subtitle': subtitle,
});
} on PlatformException catch (error) {
_lastError = error.message ?? error.code;
_setStatus(CastingStatus.error);
}
}
Future<void> disconnect() async {
if (!isSupportedOnThisPlatform) {
_setStatus(CastingStatus.unavailable);
return;
}
try {
await _methodChannel.invokeMethod<void>('disconnect');
} on PlatformException catch (error) {
_lastError = error.message ?? error.code;
_setStatus(CastingStatus.error);
}
}
Future<void> stopPlayback() async {
if (!isSupportedOnThisPlatform) {
_setStatus(CastingStatus.unavailable);
return;
}
try {
await _methodChannel.invokeMethod<void>('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();
}
}
+177 -9
View File
@@ -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<MyApp> {
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,17 @@ class _MyAppState extends State<MyApp> {
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<MainPage> createState() => _MainPageState();
@@ -63,24 +74,62 @@ class MainPage extends StatefulWidget {
class _MainPageState extends State<MainPage> {
StreamingAudioBusinessObject get _audio => widget.audio;
CastingBusinessObject get _casting => widget.casting;
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
static const List<int> _intervalOptions = <int>[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:
if (_isPlaybackBuffering) {
return 'Buffering...';
case StreamingPlaybackStatus.playing:
}
if (_isPlaybackPlaying) {
return 'Playing';
}
return 'Stopped';
}
@override
void initState() {
super.initState();
_audio.addListener(_handleAudioStateChanged);
_casting.addListener(_handleCastingStateChanged);
_liveInfo.addListener(_handleLiveInfoChanged);
_liveInfo.start();
}
@@ -105,17 +154,127 @@ class _MainPageState extends State<MainPage> {
});
}
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<void> _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<void> _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<void> _stop() async {
if (_usesExternalCastPlayback) {
await _casting.stopPlayback();
return;
}
await _audio.stop();
}
Future<void> _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 +384,24 @@ class _MainPageState extends State<MainPage> {
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(