Adds Apple airplay/Google cast support to the app #2
@@ -5,6 +5,12 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
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 {
|
android {
|
||||||
namespace = "com.example.kryz_go_flutter"
|
namespace = "com.example.kryz_go_flutter"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="KRYZ Go!"
|
android:label="KRYZ Go!"
|
||||||
@@ -37,6 +38,9 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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
|
<service
|
||||||
android:name="com.ryanheise.audioservice.AudioService"
|
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
|
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 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.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
<item name="android:windowIsTranslucent">false</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</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.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">@android:color/white</item>
|
||||||
|
<item name="android:windowIsTranslucent">false</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import AVKit
|
||||||
|
|
||||||
@main
|
@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(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
@@ -17,10 +27,137 @@ import AVFoundation
|
|||||||
NSLog("Failed to configure AVAudioSession: \(error)")
|
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)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
-9
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:just_audio_background/just_audio_background.dart';
|
import 'package:just_audio_background/just_audio_background.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'app_theme_business_object.dart';
|
import 'app_theme_business_object.dart';
|
||||||
|
import 'casting_business_object.dart';
|
||||||
import 'live_info_business_object.dart';
|
import 'live_info_business_object.dart';
|
||||||
import 'live_info_panel.dart';
|
import 'live_info_panel.dart';
|
||||||
import 'streaming_audio_business_object.dart';
|
import 'streaming_audio_business_object.dart';
|
||||||
@@ -32,9 +34,17 @@ class MyApp extends StatefulWidget {
|
|||||||
|
|
||||||
class _MyAppState extends State<MyApp> {
|
class _MyAppState extends State<MyApp> {
|
||||||
final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject();
|
final StreamingAudioBusinessObject _audio = StreamingAudioBusinessObject();
|
||||||
|
final CastingBusinessObject _casting = CastingBusinessObject();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_casting.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_casting.dispose();
|
||||||
_audio.dispose();
|
_audio.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -46,16 +56,21 @@ class _MyAppState extends State<MyApp> {
|
|||||||
theme: AppThemeBusinessObject.lightTheme(),
|
theme: AppThemeBusinessObject.lightTheme(),
|
||||||
darkTheme: AppThemeBusinessObject.darkTheme(),
|
darkTheme: AppThemeBusinessObject.darkTheme(),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
home: MainPage(audio: _audio),
|
home: MainPage(audio: _audio, casting: _casting),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MainPage extends StatefulWidget {
|
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 StreamingAudioBusinessObject audio;
|
||||||
|
final CastingBusinessObject casting;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MainPage> createState() => _MainPageState();
|
State<MainPage> createState() => _MainPageState();
|
||||||
@@ -63,24 +78,61 @@ class MainPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _MainPageState extends State<MainPage> {
|
class _MainPageState extends State<MainPage> {
|
||||||
StreamingAudioBusinessObject get _audio => widget.audio;
|
StreamingAudioBusinessObject get _audio => widget.audio;
|
||||||
|
CastingBusinessObject get _casting => widget.casting;
|
||||||
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
|
final LiveInfoBusinessObject _liveInfo = LiveInfoBusinessObject();
|
||||||
static const List<int> _intervalOptions = <int>[5, 10, 15, 30, 60];
|
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() {
|
String _playbackStatusLabel() {
|
||||||
switch (_audio.playbackStatus) {
|
if (_isPlaybackBuffering) {
|
||||||
case StreamingPlaybackStatus.stopped:
|
|
||||||
return 'Stopped';
|
|
||||||
case StreamingPlaybackStatus.buffering:
|
|
||||||
return 'Buffering...';
|
return 'Buffering...';
|
||||||
case StreamingPlaybackStatus.playing:
|
}
|
||||||
|
|
||||||
|
if (_isPlaybackPlaying) {
|
||||||
return 'Playing';
|
return 'Playing';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return 'Stopped';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_audio.addListener(_handleAudioStateChanged);
|
_audio.addListener(_handleAudioStateChanged);
|
||||||
|
_casting.addListener(_handleCastingStateChanged);
|
||||||
_liveInfo.addListener(_handleLiveInfoChanged);
|
_liveInfo.addListener(_handleLiveInfoChanged);
|
||||||
_liveInfo.start();
|
_liveInfo.start();
|
||||||
}
|
}
|
||||||
@@ -105,17 +157,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 {
|
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();
|
await _audio.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _stop() async {
|
Future<void> _stop() async {
|
||||||
|
if (_usesExternalCastPlayback) {
|
||||||
|
await _casting.stopPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _audio.stop();
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_audio.removeListener(_handleAudioStateChanged);
|
_audio.removeListener(_handleAudioStateChanged);
|
||||||
|
_casting.removeListener(_handleCastingStateChanged);
|
||||||
_liveInfo.removeListener(_handleLiveInfoChanged);
|
_liveInfo.removeListener(_handleLiveInfoChanged);
|
||||||
_liveInfo.dispose();
|
_liveInfo.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -225,15 +387,24 @@ class _MainPageState extends State<MainPage> {
|
|||||||
runSpacing: 12,
|
runSpacing: 12,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: _audio.isStopped ? _play : null,
|
onPressed: _isPlaybackStopped ? _play : null,
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
label: const Text('Play'),
|
label: const Text('Play'),
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: _audio.isStopped ? null : _stop,
|
onPressed: _isPlaybackStopped ? null : _stop,
|
||||||
icon: const Icon(Icons.stop_rounded),
|
icon: const Icon(Icons.stop_rounded),
|
||||||
label: const Text('Stop'),
|
label: const Text('Stop'),
|
||||||
),
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed:
|
||||||
|
_casting.status == CastingStatus.connecting ||
|
||||||
|
_casting.status == CastingStatus.unavailable
|
||||||
|
? null
|
||||||
|
: _toggleCasting,
|
||||||
|
icon: Icon(_castingIcon()),
|
||||||
|
label: Text(_castingLabel()),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
_playbackStatusLabel(),
|
_playbackStatusLabel(),
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
|||||||
Reference in New Issue
Block a user