|
|
|
@@ -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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|