Adds Apple airplay/Google cast support to the app
This commit is contained in:
1 parent
3bb2630e1a
commit
c9ef69c8b5
10 files changed
+979
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user