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..c3c1dd7
--- /dev/null
+++ b/lib/casting_business_object.dart
@@ -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? _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;
+ }
+
+ if (defaultTargetPlatform == TargetPlatform.android) {
+ _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..f35b924 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,17 @@ 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 +74,62 @@ 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 +154,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 +384,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(