feat(cast): add local Cast route picker
- Replace the hidden Android route button with an in-app route sheet - Wire route discovery and selection through the stream proxy bridge - Add cast picker state handling and widget coverage
This commit is contained in:
parent
dbf606c1da
commit
7c0facb8f2
12 changed files with 657 additions and 167 deletions
|
|
@ -1,20 +1,16 @@
|
|||
package com.namecallfilter.glacier.cast
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.Gravity
|
||||
import android.widget.FrameLayout
|
||||
import androidx.mediarouter.app.MediaRouteButton
|
||||
import androidx.mediarouter.media.MediaRouteSelector
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
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.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.CastButtonFactory
|
||||
import com.namecallfilter.glacier.R
|
||||
import com.namecallfilter.glacier.streamproxy.CastRelayServer
|
||||
import com.namecallfilter.glacier.streamproxy.StreamProxyConfig
|
||||
import com.namecallfilter.glacier.streamproxy.StreamProxySessionRegistry
|
||||
|
|
@ -22,13 +18,22 @@ import com.namecallfilter.glacier.streamproxy.StreamProxySessionRegistry
|
|||
class GlacierCastController(
|
||||
context: Context,
|
||||
private val onStateChanged: (Map<String, Any?>) -> Unit = {},
|
||||
private val onRoutesChanged: (Map<String, Any?>) -> Unit = {},
|
||||
) {
|
||||
private val activity = context as? Activity
|
||||
private val applicationContext = context.applicationContext
|
||||
private val receiverApplicationId =
|
||||
GlacierCastReceiverConfig.receiverApplicationId(applicationContext)
|
||||
private val receiverApplicationIdDescription =
|
||||
GlacierCastReceiverConfig.maskedReceiverApplicationId(applicationContext)
|
||||
private val relayServer = CastRelayServer(::log)
|
||||
private val sessionListener = CastSessionListener()
|
||||
private val mediaRouter: MediaRouter by lazy {
|
||||
MediaRouter.getInstance(applicationContext)
|
||||
}
|
||||
private val routeSelector = MediaRouteSelector.Builder()
|
||||
.addControlCategory(CastMediaControlIntent.categoryForCast(receiverApplicationId))
|
||||
.build()
|
||||
private val routeCallback = CastRouteCallback()
|
||||
|
||||
@Volatile
|
||||
private var castContext: CastContext? = null
|
||||
|
|
@ -43,7 +48,13 @@ class GlacierCastController(
|
|||
private var receiverLatencyMs: Long? = null
|
||||
|
||||
@Volatile
|
||||
private var routeButton: MediaRouteButton? = null
|
||||
private var routeDiscoveryActive = false
|
||||
|
||||
@Volatile
|
||||
private var connectingRouteId: String? = null
|
||||
|
||||
@Volatile
|
||||
private var connectingRouteName: String? = null
|
||||
|
||||
init {
|
||||
runCatching {
|
||||
|
|
@ -76,85 +87,90 @@ class GlacierCastController(
|
|||
}
|
||||
}
|
||||
|
||||
fun showCastDialog() {
|
||||
fun startRouteDiscovery() {
|
||||
routeDiscoveryActive = true
|
||||
runCatching {
|
||||
mediaRouter.addCallback(
|
||||
routeSelector,
|
||||
routeCallback,
|
||||
MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY or
|
||||
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN,
|
||||
)
|
||||
emitRoutes()
|
||||
}.onFailure { error ->
|
||||
emitRoutes(error = "Unable to search for Cast devices.")
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast_routes action=start_failed reason=${error.javaClass.simpleName} " +
|
||||
"message=${error.message}",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRouteDiscovery() {
|
||||
if (!routeDiscoveryActive) return
|
||||
|
||||
routeDiscoveryActive = false
|
||||
runCatching {
|
||||
mediaRouter.removeCallback(routeCallback)
|
||||
emitRoutes()
|
||||
}.onFailure { error ->
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast_routes action=stop_failed reason=${error.javaClass.simpleName} " +
|
||||
"message=${error.message}",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRoute(routeId: String) {
|
||||
val session = currentSession()
|
||||
if (session?.isConnected == true) {
|
||||
emitState(session)
|
||||
emitRoutes()
|
||||
return
|
||||
}
|
||||
|
||||
val route = castRoutes().firstOrNull { route -> route.id == routeId }
|
||||
if (route == null) {
|
||||
connectingRouteId = null
|
||||
connectingRouteName = null
|
||||
emitRoutes(error = "Cast device is no longer available.")
|
||||
return
|
||||
}
|
||||
|
||||
prepareLoad()
|
||||
connectingRouteId = route.id
|
||||
connectingRouteName = route.name.toString()
|
||||
emitRoutes()
|
||||
|
||||
val button = routeButton ?: ensureRouteButton()
|
||||
if (button == null) {
|
||||
Log.d(LOG_TAG, "cast_button action=show_failed reason=route_button_unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
button.post {
|
||||
runCatching {
|
||||
button.performClick()
|
||||
}.onFailure { error ->
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast_button action=show_failed reason=${error.javaClass.simpleName} " +
|
||||
"message=${error.message}",
|
||||
error,
|
||||
)
|
||||
}
|
||||
runCatching {
|
||||
route.select()
|
||||
}.onFailure { error ->
|
||||
pendingLoad = false
|
||||
connectingRouteId = null
|
||||
connectingRouteName = null
|
||||
emitRoutes(error = "Unable to connect to Cast device.")
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast_routes action=select_failed reason=${error.javaClass.simpleName} " +
|
||||
"message=${error.message}",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopCasting() {
|
||||
pendingLoad = false
|
||||
receiverLatencyMs = null
|
||||
connectingRouteId = null
|
||||
connectingRouteName = null
|
||||
castContext?.sessionManager?.endCurrentSession(true)
|
||||
relayServer.close()
|
||||
emitDisconnected()
|
||||
}
|
||||
|
||||
private fun ensureRouteButton(): MediaRouteButton? {
|
||||
routeButton?.let { button ->
|
||||
if (button.isAttachedToWindow) return button
|
||||
}
|
||||
|
||||
val activity = activity ?: return null
|
||||
return try {
|
||||
val buttonContext = ContextThemeWrapper(
|
||||
activity,
|
||||
R.style.GlacierCastRouteButtonTheme,
|
||||
)
|
||||
val button = object : MediaRouteButton(buttonContext) {
|
||||
override fun performClick(): Boolean {
|
||||
prepareLoad()
|
||||
return super.performClick()
|
||||
}
|
||||
}
|
||||
button.alpha = 0f
|
||||
@Suppress("DEPRECATION")
|
||||
button.setAlwaysVisible(true)
|
||||
|
||||
CastButtonFactory.setUpMediaRouteButton(activity, button)
|
||||
activity.addContentView(
|
||||
button,
|
||||
FrameLayout.LayoutParams(
|
||||
1,
|
||||
1,
|
||||
Gravity.TOP or Gravity.START,
|
||||
),
|
||||
)
|
||||
routeButton = button
|
||||
Log.d(LOG_TAG, "cast_button action=attached")
|
||||
button
|
||||
} catch (error: Exception) {
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast_button action=setup_failed reason=${error.javaClass.simpleName} " +
|
||||
"message=${error.message}",
|
||||
error,
|
||||
)
|
||||
null
|
||||
}
|
||||
emitRoutes()
|
||||
}
|
||||
|
||||
private fun loadCurrent() {
|
||||
|
|
@ -277,6 +293,33 @@ class GlacierCastController(
|
|||
)
|
||||
}
|
||||
|
||||
private fun emitRoutes(error: String? = null) {
|
||||
val isCasting = currentSession()?.isConnected == true
|
||||
onRoutesChanged(
|
||||
mapOf(
|
||||
"isSearching" to routeDiscoveryActive,
|
||||
"isConnecting" to (!isCasting && connectingRouteId != null),
|
||||
"connectingRouteName" to connectingRouteName,
|
||||
"routes" to castRoutes().map { route ->
|
||||
mapOf(
|
||||
"id" to route.id,
|
||||
"name" to route.name.toString(),
|
||||
"description" to route.description?.toString(),
|
||||
)
|
||||
},
|
||||
"error" to error,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun castRoutes(): List<MediaRouter.RouteInfo> {
|
||||
return mediaRouter.routes.filter { route ->
|
||||
route.isEnabled &&
|
||||
!route.isDefault &&
|
||||
route.matchesSelector(routeSelector)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitDisconnected() {
|
||||
onStateChanged(
|
||||
mapOf(
|
||||
|
|
@ -294,12 +337,17 @@ class GlacierCastController(
|
|||
}
|
||||
|
||||
private inner class CastSessionListener : SessionManagerListener<CastSession> {
|
||||
override fun onSessionStarting(session: CastSession) = Unit
|
||||
override fun onSessionStarting(session: CastSession) {
|
||||
emitRoutes()
|
||||
}
|
||||
|
||||
override fun onSessionStarted(session: CastSession, sessionId: String) {
|
||||
receiverLatencyMs = null
|
||||
connectingRouteId = null
|
||||
connectingRouteName = null
|
||||
attachReceiverChannel(session)
|
||||
emitState(session)
|
||||
emitRoutes()
|
||||
if (pendingLoad) {
|
||||
loadCurrent()
|
||||
}
|
||||
|
|
@ -308,7 +356,10 @@ class GlacierCastController(
|
|||
override fun onSessionStartFailed(session: CastSession, error: Int) {
|
||||
pendingLoad = false
|
||||
receiverLatencyMs = null
|
||||
connectingRouteId = null
|
||||
connectingRouteName = null
|
||||
emitDisconnected()
|
||||
emitRoutes(error = "Unable to connect to Cast device.")
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast action=session_start_failed error=$error " +
|
||||
|
|
@ -324,8 +375,11 @@ class GlacierCastController(
|
|||
override fun onSessionEnded(session: CastSession, error: Int) {
|
||||
pendingLoad = false
|
||||
receiverLatencyMs = null
|
||||
connectingRouteId = null
|
||||
connectingRouteName = null
|
||||
relayServer.close()
|
||||
emitDisconnected()
|
||||
emitRoutes()
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast action=session_ended error=$error " +
|
||||
|
|
@ -337,8 +391,11 @@ class GlacierCastController(
|
|||
override fun onSessionResuming(session: CastSession, sessionId: String) = Unit
|
||||
|
||||
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
|
||||
connectingRouteId = null
|
||||
connectingRouteName = null
|
||||
attachReceiverChannel(session)
|
||||
emitState(session)
|
||||
emitRoutes()
|
||||
if (pendingLoad) {
|
||||
loadCurrent()
|
||||
}
|
||||
|
|
@ -346,7 +403,10 @@ class GlacierCastController(
|
|||
|
||||
override fun onSessionResumeFailed(session: CastSession, error: Int) {
|
||||
receiverLatencyMs = null
|
||||
connectingRouteId = null
|
||||
connectingRouteName = null
|
||||
emitDisconnected()
|
||||
emitRoutes(error = "Unable to reconnect to Cast device.")
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"cast action=session_resume_failed error=$error " +
|
||||
|
|
@ -361,6 +421,46 @@ class GlacierCastController(
|
|||
}
|
||||
}
|
||||
|
||||
private inner class CastRouteCallback : MediaRouter.Callback() {
|
||||
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
emitRoutes()
|
||||
}
|
||||
|
||||
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
emitRoutes()
|
||||
}
|
||||
|
||||
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
if (route.id == connectingRouteId) {
|
||||
connectingRouteId = null
|
||||
connectingRouteName = null
|
||||
}
|
||||
emitRoutes()
|
||||
}
|
||||
|
||||
override fun onRouteSelected(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
reason: Int,
|
||||
) {
|
||||
connectingRouteId = route.id
|
||||
connectingRouteName = route.name.toString()
|
||||
emitRoutes()
|
||||
}
|
||||
|
||||
override fun onRouteUnselected(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
reason: Int,
|
||||
) {
|
||||
if (route.id == connectingRouteId) {
|
||||
connectingRouteId = null
|
||||
connectingRouteName = null
|
||||
}
|
||||
emitRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val LOG_TAG = "GlacierCast"
|
||||
private const val CAST_NAMESPACE = "urn:x-cast:com.namecallfilter.glacier.cast"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ object StreamProxyChannel {
|
|||
methodChannel.invokeMethod("castStateChanged", state)
|
||||
}
|
||||
},
|
||||
onRoutesChanged = { state ->
|
||||
mainHandler.post {
|
||||
methodChannel.invokeMethod("castRoutesChanged", state)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
methodChannel.setMethodCallHandler { call, result ->
|
||||
|
|
@ -112,9 +117,27 @@ object StreamProxyChannel {
|
|||
)
|
||||
result.success(null)
|
||||
}
|
||||
"showCastDialog" -> {
|
||||
"startCastRouteDiscovery" -> {
|
||||
mainHandler.post {
|
||||
castController.showCastDialog()
|
||||
castController.startRouteDiscovery()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"stopCastRouteDiscovery" -> {
|
||||
mainHandler.post {
|
||||
castController.stopRouteDiscovery()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"selectCastRoute" -> {
|
||||
val routeId = stringArgument(call, "routeId")
|
||||
?: return@setMethodCallHandler result.error(
|
||||
"missing_route_id",
|
||||
"Missing routeId",
|
||||
null,
|
||||
)
|
||||
mainHandler.post {
|
||||
castController.selectRoute(routeId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
|
||||
<item name="colorPrimary">#000000</item>
|
||||
<item name="colorPrimaryDark">#000000</item>
|
||||
<item name="colorAccent">#FFFFFF</item>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
|
||||
<item name="colorPrimary">#000000</item>
|
||||
<item name="colorPrimaryDark">#000000</item>
|
||||
<item name="colorAccent">#FFFFFF</item>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
|
||||
<item name="colorPrimary">#000000</item>
|
||||
<item name="colorPrimaryDark">#000000</item>
|
||||
<item name="colorAccent">#FFFFFF</item>
|
||||
|
|
|
|||
|
|
@ -17,20 +17,9 @@
|
|||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="mediaRouteTheme">@style/GlacierCastRouteButtonTheme</item>
|
||||
<item name="colorPrimary">#000000</item>
|
||||
<item name="colorPrimaryDark">#000000</item>
|
||||
<item name="colorAccent">#FFFFFF</item>
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
<style name="GlacierCastRouteButtonTheme" parent="@style/Theme.AppCompat.NoActionBar">
|
||||
<item name="colorPrimary">#000000</item>
|
||||
<item name="colorPrimaryDark">#000000</item>
|
||||
<item name="colorAccent">#FFFFFF</item>
|
||||
<item name="android:colorBackground">#000000</item>
|
||||
<item name="android:colorAccent">#FFFFFF</item>
|
||||
<item name="android:windowBackground">#000000</item>
|
||||
<item name="android:windowIsTranslucent">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,89 +1,256 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:frosty/services/cast_route_picker_state.dart';
|
||||
import 'package:frosty/services/cast_state.dart';
|
||||
import 'package:frosty/services/stream_proxy_bridge.dart';
|
||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||
import 'package:frosty/widgets/section_header.dart';
|
||||
|
||||
class CastButton extends StatelessWidget {
|
||||
final Color? color;
|
||||
final List<Shadow>? shadows;
|
||||
final bool? isSupported;
|
||||
|
||||
const CastButton({super.key, this.color, this.shadows});
|
||||
const CastButton({super.key, this.color, this.shadows, this.isSupported});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!Platform.isAndroid) return const SizedBox.shrink();
|
||||
if (!(isSupported ?? Platform.isAndroid)) return const SizedBox.shrink();
|
||||
|
||||
return ValueListenableBuilder<CastState>(
|
||||
valueListenable: StreamProxyBridge.castState,
|
||||
builder: (context, castState, _) => SizedBox.square(
|
||||
dimension: 48,
|
||||
child: IconButton(
|
||||
tooltip: castState.isCasting
|
||||
? 'Casting to ${castState.receiverName ?? 'device'}'
|
||||
: 'Cast',
|
||||
icon: Icon(
|
||||
castState.isCasting
|
||||
? Icons.cast_connected_rounded
|
||||
: Icons.cast_rounded,
|
||||
color: castState.isCasting ? Colors.white : color,
|
||||
shadows: shadows,
|
||||
builder: (context, castState, _) {
|
||||
if (castState.isCasting) return const SizedBox.shrink();
|
||||
|
||||
return SizedBox.square(
|
||||
dimension: 48,
|
||||
child: IconButton(
|
||||
tooltip: 'Cast',
|
||||
icon: Icon(Icons.cast_rounded, color: color, shadows: shadows),
|
||||
onPressed: () => showCastPicker(context),
|
||||
),
|
||||
onPressed: () => _handlePressed(context, castState),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePressed(BuildContext context, CastState castState) async {
|
||||
if (!castState.isCasting) {
|
||||
await StreamProxyBridge.showCastDialog();
|
||||
return;
|
||||
}
|
||||
class CastStatusButton extends StatelessWidget {
|
||||
final CastState castState;
|
||||
final Color? color;
|
||||
final List<Shadow>? shadows;
|
||||
|
||||
if (!context.mounted) return;
|
||||
await _showCastControls(context, castState);
|
||||
const CastStatusButton({
|
||||
super.key,
|
||||
required this.castState,
|
||||
this.color,
|
||||
this.shadows,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
tooltip: _castTooltip(castState),
|
||||
iconSize: 56,
|
||||
icon: Icon(Icons.cast_connected_rounded, color: color, shadows: shadows),
|
||||
onPressed: () => showCastPicker(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _castTooltip(CastState castState) {
|
||||
if (castState.receiverName == null) return 'Casting';
|
||||
|
||||
return 'Casting to ${castState.receiverName}';
|
||||
}
|
||||
|
||||
Future<void> showCastPicker(BuildContext context) {
|
||||
return showModalBottomSheetWithProperFocus<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => const _CastRoutePickerSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _CastRoutePickerSheet extends StatefulWidget {
|
||||
const _CastRoutePickerSheet();
|
||||
|
||||
@override
|
||||
State<_CastRoutePickerSheet> createState() => _CastRoutePickerSheetState();
|
||||
}
|
||||
|
||||
class _CastRoutePickerSheetState extends State<_CastRoutePickerSheet> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(StreamProxyBridge.startCastRouteDiscovery());
|
||||
}
|
||||
|
||||
Future<void> _showCastControls(BuildContext context, CastState castState) {
|
||||
final receiverName = castState.receiverName ?? 'Cast device';
|
||||
final latency = castState.latencySeconds;
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(StreamProxyBridge.stopCastRouteDiscovery());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
return showModalBottomSheetWithProperFocus<void>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.cast_connected_rounded),
|
||||
title: Text(receiverName),
|
||||
subtitle: latency == null
|
||||
? const Text('Casting')
|
||||
: Text('Casting - ${latency}s latency'),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Close',
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: Navigator.of(context).pop,
|
||||
),
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: ValueListenableBuilder<CastState>(
|
||||
valueListenable: StreamProxyBridge.castState,
|
||||
builder: (context, castState, _) =>
|
||||
ValueListenableBuilder<CastRoutePickerState>(
|
||||
valueListenable: StreamProxyBridge.castRoutePickerState,
|
||||
builder: (context, pickerState, _) => _CastRoutePickerContent(
|
||||
castState: castState,
|
||||
pickerState: pickerState,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.stop_rounded),
|
||||
label: const Text('Stop casting'),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await StreamProxyBridge.stopCasting();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CastRoutePickerContent extends StatelessWidget {
|
||||
final CastState castState;
|
||||
final CastRoutePickerState pickerState;
|
||||
|
||||
const _CastRoutePickerContent({
|
||||
required this.castState,
|
||||
required this.pickerState,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionHeader(
|
||||
'Cast to device',
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
isFirst: true,
|
||||
),
|
||||
if (castState.isCasting)
|
||||
_ConnectedCastControls(castState: castState)
|
||||
else if (pickerState.isConnecting)
|
||||
const ListTile(
|
||||
leading: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
title: Text('Connecting...'),
|
||||
)
|
||||
else ...[
|
||||
if (pickerState.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(
|
||||
pickerState.error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
if (pickerState.routes.isEmpty)
|
||||
_EmptyRouteListTile(
|
||||
isSearching: pickerState.isSearching,
|
||||
hasError: pickerState.error != null,
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
children: pickerState.routes
|
||||
.map((route) => _CastRouteTile(route: route))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CastRouteTile extends StatelessWidget {
|
||||
final CastRoute route;
|
||||
|
||||
const _CastRouteTile({required this.route});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.tv_rounded),
|
||||
title: Text(route.name),
|
||||
subtitle: route.description == null ? null : Text(route.description!),
|
||||
onTap: () => StreamProxyBridge.selectCastRoute(route),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyRouteListTile extends StatelessWidget {
|
||||
final bool isSearching;
|
||||
final bool hasError;
|
||||
|
||||
const _EmptyRouteListTile({
|
||||
required this.isSearching,
|
||||
required this.hasError,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isSearching) {
|
||||
return const ListTile(
|
||||
leading: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
title: Text('Searching for devices...'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.cast_rounded),
|
||||
title: Text(hasError ? 'Unable to find devices' : 'No devices found'),
|
||||
trailing: TextButton(
|
||||
onPressed: StreamProxyBridge.startCastRouteDiscovery,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectedCastControls extends StatelessWidget {
|
||||
final CastState castState;
|
||||
|
||||
const _ConnectedCastControls({required this.castState});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final receiverName = castState.receiverName ?? 'Cast device';
|
||||
final latency = castState.latencySeconds;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 0, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cast_connected_rounded),
|
||||
title: Text(receiverName),
|
||||
subtitle: latency == null
|
||||
? const Text('Casting')
|
||||
: Text('Casting - ${latency}s latency'),
|
||||
trailing: TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await StreamProxyBridge.stopCasting();
|
||||
},
|
||||
child: const Text('Disconnect'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -348,23 +348,16 @@ class VideoOverlay extends StatelessWidget {
|
|||
valueListenable: StreamProxyBridge.castState,
|
||||
builder: (context, castState, _) {
|
||||
if (castState.isCasting) {
|
||||
return Tooltip(
|
||||
message: castState.receiverName == null
|
||||
? 'Casting'
|
||||
: 'Casting to ${castState.receiverName}',
|
||||
preferBelow: false,
|
||||
child: Icon(
|
||||
Icons.cast_connected_rounded,
|
||||
size: 56,
|
||||
color: surfaceColor,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 3),
|
||||
blurRadius: 8,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
return CastStatusButton(
|
||||
castState: castState,
|
||||
color: surfaceColor,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 3),
|
||||
blurRadius: 8,
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
94
lib/services/cast_route_picker_state.dart
Normal file
94
lib/services/cast_route_picker_state.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
class CastRoute {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? description;
|
||||
|
||||
const CastRoute({required this.id, required this.name, this.description});
|
||||
|
||||
factory CastRoute.fromMethodChannelPayload(Object? payload) {
|
||||
if (payload is! Map) {
|
||||
return const CastRoute(id: '', name: 'Cast device');
|
||||
}
|
||||
|
||||
final id = payload['id'];
|
||||
final name = payload['name'];
|
||||
final description = payload['description'];
|
||||
|
||||
return CastRoute(
|
||||
id: id is String ? id : '',
|
||||
name: name is String && name.trim().isNotEmpty
|
||||
? name.trim()
|
||||
: 'Cast device',
|
||||
description: description is String && description.trim().isNotEmpty
|
||||
? description.trim()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CastRoutePickerState {
|
||||
final bool isSearching;
|
||||
final bool isConnecting;
|
||||
final String? connectingRouteName;
|
||||
final List<CastRoute> routes;
|
||||
final String? error;
|
||||
|
||||
const CastRoutePickerState({
|
||||
required this.isSearching,
|
||||
required this.isConnecting,
|
||||
required this.routes,
|
||||
this.connectingRouteName,
|
||||
this.error,
|
||||
});
|
||||
|
||||
const CastRoutePickerState.idle()
|
||||
: isSearching = false,
|
||||
isConnecting = false,
|
||||
connectingRouteName = null,
|
||||
routes = const [],
|
||||
error = null;
|
||||
|
||||
CastRoutePickerState copyWith({
|
||||
bool? isSearching,
|
||||
bool? isConnecting,
|
||||
String? connectingRouteName,
|
||||
bool clearConnectingRouteName = false,
|
||||
List<CastRoute>? routes,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return CastRoutePickerState(
|
||||
isSearching: isSearching ?? this.isSearching,
|
||||
isConnecting: isConnecting ?? this.isConnecting,
|
||||
connectingRouteName: clearConnectingRouteName
|
||||
? null
|
||||
: connectingRouteName ?? this.connectingRouteName,
|
||||
routes: routes ?? this.routes,
|
||||
error: clearError ? null : error ?? this.error,
|
||||
);
|
||||
}
|
||||
|
||||
factory CastRoutePickerState.fromMethodChannelPayload(Object? payload) {
|
||||
if (payload is! Map) return const CastRoutePickerState.idle();
|
||||
|
||||
final routes = payload['routes'];
|
||||
final connectingRouteName = payload['connectingRouteName'];
|
||||
final error = payload['error'];
|
||||
|
||||
return CastRoutePickerState(
|
||||
isSearching: payload['isSearching'] == true,
|
||||
isConnecting: payload['isConnecting'] == true,
|
||||
connectingRouteName:
|
||||
connectingRouteName is String && connectingRouteName.trim().isNotEmpty
|
||||
? connectingRouteName.trim()
|
||||
: null,
|
||||
routes: routes is List
|
||||
? routes
|
||||
.map(CastRoute.fromMethodChannelPayload)
|
||||
.where((route) => route.id.isNotEmpty)
|
||||
.toList(growable: false)
|
||||
: const [],
|
||||
error: error is String && error.trim().isNotEmpty ? error.trim() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:frosty/services/cast_route_picker_state.dart';
|
||||
import 'package:frosty/services/cast_state.dart';
|
||||
import 'package:frosty/services/stream_proxy_config.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
|
@ -11,6 +12,9 @@ class StreamProxyBridge {
|
|||
static final castState = ValueNotifier<CastState>(
|
||||
const CastState.disconnected(),
|
||||
);
|
||||
static final castRoutePickerState = ValueNotifier<CastRoutePickerState>(
|
||||
const CastRoutePickerState.idle(),
|
||||
);
|
||||
static StreamProxyBridge? _activeBridge;
|
||||
static var _methodCallHandlerAttached = false;
|
||||
|
||||
|
|
@ -90,9 +94,31 @@ class StreamProxyBridge {
|
|||
await _invokeStatic(method, arguments);
|
||||
}
|
||||
|
||||
static Future<void> showCastDialog() async {
|
||||
static Future<void> startCastRouteDiscovery() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
await _invokeStatic('showCastDialog', const {});
|
||||
castRoutePickerState.value = castRoutePickerState.value.copyWith(
|
||||
isSearching: true,
|
||||
clearError: true,
|
||||
);
|
||||
await _invokeStatic('startCastRouteDiscovery', const {});
|
||||
}
|
||||
|
||||
static Future<void> stopCastRouteDiscovery() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
castRoutePickerState.value = castRoutePickerState.value.copyWith(
|
||||
isSearching: false,
|
||||
);
|
||||
await _invokeStatic('stopCastRouteDiscovery', const {});
|
||||
}
|
||||
|
||||
static Future<void> selectCastRoute(CastRoute route) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
castRoutePickerState.value = castRoutePickerState.value.copyWith(
|
||||
isConnecting: true,
|
||||
connectingRouteName: route.name,
|
||||
clearError: true,
|
||||
);
|
||||
await _invokeStatic('selectCastRoute', {'routeId': route.id});
|
||||
}
|
||||
|
||||
static Future<void> stopCasting() async {
|
||||
|
|
@ -132,6 +158,10 @@ class StreamProxyBridge {
|
|||
case 'castStateChanged':
|
||||
castState.value = CastState.fromMethodChannelPayload(call.arguments);
|
||||
return;
|
||||
case 'castRoutesChanged':
|
||||
castRoutePickerState.value =
|
||||
CastRoutePickerState.fromMethodChannelPayload(call.arguments);
|
||||
return;
|
||||
default:
|
||||
debugPrint('Unknown stream proxy bridge method: ${call.method}');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Future<T?> showModalBottomSheetWithProperFocus<T>({
|
|||
String? barrierLabel,
|
||||
AnimationController? transitionAnimationController,
|
||||
Offset? anchorPoint,
|
||||
bool? showDragHandle,
|
||||
}) {
|
||||
// Clear focus and its history so it can't be auto-restored
|
||||
FocusScope.of(context).unfocus();
|
||||
|
|
@ -40,6 +41,7 @@ Future<T?> showModalBottomSheetWithProperFocus<T>({
|
|||
barrierLabel: barrierLabel,
|
||||
transitionAnimationController: transitionAnimationController,
|
||||
anchorPoint: anchorPoint,
|
||||
showDragHandle: showDragHandle,
|
||||
).whenComplete(() {
|
||||
// Additional safety: clear any focus that might have been restored
|
||||
if (context.mounted) {
|
||||
|
|
|
|||
95
test/screens/channel/video/cast_button_test.dart
Normal file
95
test/screens/channel/video/cast_button_test.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/screens/channel/video/cast_button.dart';
|
||||
import 'package:frosty/services/cast_route_picker_state.dart';
|
||||
import 'package:frosty/services/cast_state.dart';
|
||||
import 'package:frosty/services/stream_proxy_bridge.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
StreamProxyBridge.castState.value = const CastState.disconnected();
|
||||
StreamProxyBridge.castRoutePickerState.value =
|
||||
const CastRoutePickerState.idle();
|
||||
});
|
||||
|
||||
testWidgets('hides the header cast button while casting', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: Scaffold(body: CastButton(isSupported: true))),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.cast_rounded), findsOneWidget);
|
||||
|
||||
StreamProxyBridge.castState.value = const CastState(isCasting: true);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byIcon(Icons.cast_rounded), findsNothing);
|
||||
expect(find.byIcon(Icons.cast_connected_rounded), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('header cast button opens the cast route sheet', (tester) async {
|
||||
StreamProxyBridge.castRoutePickerState.value = const CastRoutePickerState(
|
||||
isSearching: false,
|
||||
isConnecting: false,
|
||||
routes: [
|
||||
CastRoute(id: 'route-1', name: 'BRAVIA 4K UR1 - Living Room TV'),
|
||||
CastRoute(id: 'route-2', name: 'Chromecast - Jacoby\'s TV'),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: Scaffold(body: CastButton(isSupported: true))),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Cast'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('CAST TO DEVICE'), findsOneWidget);
|
||||
expect(find.text('BRAVIA 4K UR1 - Living Room TV'), findsOneWidget);
|
||||
expect(find.text('Chromecast - Jacoby\'s TV'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('centered cast icon opens the connected cast sheet', (
|
||||
tester,
|
||||
) async {
|
||||
const castState = CastState(
|
||||
isCasting: true,
|
||||
receiverName: 'Jacoby\'s TV',
|
||||
latency: Duration(milliseconds: 7420),
|
||||
);
|
||||
StreamProxyBridge.castState.value = castState;
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(body: CastStatusButton(castState: castState)),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Casting to Jacoby\'s TV'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('CAST TO DEVICE'), findsOneWidget);
|
||||
expect(find.text('Jacoby\'s TV'), findsOneWidget);
|
||||
expect(find.text('Casting - 7s latency'), findsOneWidget);
|
||||
expect(find.text('Disconnect'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('cast route sheet shows connecting state', (tester) async {
|
||||
StreamProxyBridge.castRoutePickerState.value = const CastRoutePickerState(
|
||||
isSearching: false,
|
||||
isConnecting: true,
|
||||
connectingRouteName: 'Jacoby\'s TV',
|
||||
routes: [],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: Scaffold(body: CastButton(isSupported: true))),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Cast'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('CAST TO DEVICE'), findsOneWidget);
|
||||
expect(find.text('Connecting...'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue