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:
kaoru 2026-06-10 15:22:00 -04:00
parent dbf606c1da
commit 7c0facb8f2
12 changed files with 657 additions and 167 deletions

View file

@ -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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'),
),
),
],
),
);
}

View file

@ -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),
),
],
);
}

View 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,
);
}
}

View file

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

View file

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

View 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);
});
}