Adds Apple airplay/Google cast support to the app

This commit is contained in:
2026-05-14 22:02:37 -07:00
parent 3bb2630e1a
commit c9ef69c8b5
10 changed files with 979 additions and 17 deletions
+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>