Add Companion Remote for mobile-to-desktop control

This commit is contained in:
Matt Vogel
2026-02-09 14:19:54 -05:00
parent 0dc1797eb9
commit 372767b142
31 changed files with 4140 additions and 130 deletions
@@ -0,0 +1,731 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../models/companion_remote/remote_command_type.dart';
import '../../models/companion_remote/remote_session.dart';
import '../../providers/companion_remote_provider.dart';
import '../../utils/platform_detector.dart';
import '../../utils/app_logger.dart';
import 'pairing_screen.dart';
class MobileRemoteScreen extends StatefulWidget {
const MobileRemoteScreen({super.key});
@override
State<MobileRemoteScreen> createState() => _MobileRemoteScreenState();
}
class _MobileRemoteScreenState extends State<MobileRemoteScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Companion Remote'),
actions: [
Consumer<CompanionRemoteProvider>(
builder: (context, provider, child) {
if (provider.isConnected) {
return IconButton(
icon: const Icon(Icons.link_off),
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Do you want to disconnect from the remote session?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Disconnect'),
),
],
),
);
if (confirmed == true && context.mounted) {
await context.read<CompanionRemoteProvider>().leaveSession();
}
},
tooltip: 'Disconnect',
);
}
return const SizedBox.shrink();
},
),
],
),
body: Consumer<CompanionRemoteProvider>(
builder: (context, provider, child) {
if (provider.status == RemoteSessionStatus.reconnecting) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(
'Reconnecting...',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Attempt ${provider.reconnectAttempts} of 5',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(
onPressed: () => provider.cancelReconnect(),
child: const Text('Cancel'),
),
const SizedBox(width: 16),
FilledButton(
onPressed: () => provider.retryReconnectNow(),
child: const Text('Retry Now'),
),
],
),
],
),
);
}
if (!provider.isConnected) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.phonelink_off, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
provider.status == RemoteSessionStatus.error
? provider.session?.errorMessage ?? 'Connection error'
: 'Not connected',
style: const TextStyle(fontSize: 20, color: Colors.grey),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const PairingScreen()),
);
},
icon: const Icon(Icons.link),
label: const Text('Connect to Device'),
),
],
),
);
}
return const _RemoteControlLayout();
},
),
);
}
}
class _RemoteControlLayout extends StatelessWidget {
const _RemoteControlLayout();
@override
Widget build(BuildContext context) {
if (PlatformDetector.isDesktop(context)) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: const _RemoteControlContent(),
),
);
}
return const _RemoteControlContent();
}
}
class _RemoteControlContent extends StatefulWidget {
const _RemoteControlContent();
@override
State<_RemoteControlContent> createState() => _RemoteControlContentState();
}
class _RemoteControlContentState extends State<_RemoteControlContent> {
int _selectedTab = 0;
void _showSearchSheet({bool switchToSearchTab = false}) {
if (switchToSearchTab) {
_sendCommand(RemoteCommandType.tabSearch);
}
final provider = context.read<CompanionRemoteProvider>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => _SearchBottomSheet(provider: provider),
);
}
void _sendCommand(RemoteCommandType type) {
HapticFeedback.lightImpact();
print('🔴 MobileRemoteScreen: Button pressed! Type: $type');
appLogger.d('MobileRemoteScreen: Sending command: $type');
final provider = context.read<CompanionRemoteProvider>();
print('🔴 Provider isConnected: ${provider.isConnected}');
provider.sendCommand(type);
print('🔴 sendCommand called');
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Consumer<CompanionRemoteProvider>(
builder: (context, provider, child) {
final device = provider.connectedDevice;
if (device == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.primaryContainer,
child: Row(
children: [
Icon(
Icons.computer,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
Text(
device.platform,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
),
),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
],
),
);
},
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 0, label: Text('Navigate'), icon: Icon(Icons.navigation)),
ButtonSegment(value: 1, label: Text('Playback'), icon: Icon(Icons.play_arrow)),
ButtonSegment(value: 2, label: Text('Quick'), icon: Icon(Icons.flash_on)),
],
selected: {_selectedTab},
onSelectionChanged: (Set<int> selection) {
setState(() {
_selectedTab = selection.first;
});
},
),
const SizedBox(height: 24),
if (_selectedTab == 0) _buildNavigationTab(),
if (_selectedTab == 1) _buildPlaybackTab(),
if (_selectedTab == 2) _buildQuickActionsTab(),
],
),
),
),
],
);
}
Widget _buildNavigationTab() {
return Column(
children: [
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_RemoteButton(
icon: Icons.home,
label: 'Home',
onPressed: () => _sendCommand(RemoteCommandType.home),
),
_RemoteButton(
icon: Icons.arrow_back,
label: 'Back',
onPressed: () => _sendCommand(RemoteCommandType.back),
),
_RemoteButton(
icon: Icons.menu,
label: 'Menu',
onPressed: () => _sendCommand(RemoteCommandType.contextMenu),
),
],
),
const SizedBox(height: 32),
Center(
child: _DPad(onCommand: _sendCommand),
),
const SizedBox(height: 32),
Text(
'Tab Navigation',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
_RemoteChip(
icon: Icons.explore,
label: 'Discover',
onPressed: () => _sendCommand(RemoteCommandType.tabDiscover),
),
_RemoteChip(
icon: Icons.video_library,
label: 'Libraries',
onPressed: () => _sendCommand(RemoteCommandType.tabLibraries),
),
_RemoteChip(
icon: Icons.search,
label: 'Search',
onPressed: () => _showSearchSheet(switchToSearchTab: true),
),
_RemoteChip(
icon: Icons.download,
label: 'Downloads',
onPressed: () => _sendCommand(RemoteCommandType.tabDownloads),
),
_RemoteChip(
icon: Icons.settings,
label: 'Settings',
onPressed: () => _sendCommand(RemoteCommandType.tabSettings),
),
],
),
],
);
}
Widget _buildPlaybackTab() {
return Column(
children: [
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_RemoteButton(
icon: Icons.skip_previous,
label: 'Previous',
onPressed: () => _sendCommand(RemoteCommandType.previousTrack),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.play_arrow,
label: 'Play/Pause',
size: 64,
iconSize: 36,
onPressed: () => _sendCommand(RemoteCommandType.playPause),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.skip_next,
label: 'Next',
onPressed: () => _sendCommand(RemoteCommandType.nextTrack),
),
],
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_RemoteButton(
icon: Icons.replay_10,
label: 'Seek Back',
onPressed: () => _sendCommand(RemoteCommandType.seekBackward),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.stop,
label: 'Stop',
onPressed: () => _sendCommand(RemoteCommandType.stop),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.forward_10,
label: 'Seek Fwd',
onPressed: () => _sendCommand(RemoteCommandType.seekForward),
),
],
),
const SizedBox(height: 32),
Text(
'Volume',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_RemoteButton(
icon: Icons.volume_off,
label: 'Mute',
onPressed: () => _sendCommand(RemoteCommandType.volumeMute),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.volume_down,
label: 'Down',
onPressed: () => _sendCommand(RemoteCommandType.volumeDown),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.volume_up,
label: 'Up',
onPressed: () => _sendCommand(RemoteCommandType.volumeUp),
),
],
),
],
);
}
Widget _buildQuickActionsTab() {
return Column(
children: [
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
_RemoteCard(
icon: Icons.search,
label: 'Search',
onPressed: _showSearchSheet,
),
_RemoteCard(
icon: Icons.fullscreen,
label: 'Fullscreen',
onPressed: () => _sendCommand(RemoteCommandType.fullscreen),
),
],
),
],
);
}
}
class _DPad extends StatelessWidget {
final Function(RemoteCommandType) onCommand;
const _DPad({required this.onCommand});
@override
Widget build(BuildContext context) {
const size = 80.0;
const centerSize = 60.0;
return SizedBox(
width: size * 3,
height: size * 3,
child: Stack(
children: [
Positioned(
left: size,
top: 0,
child: _DPadButton(
icon: Icons.arrow_drop_up,
onPressed: () => onCommand(RemoteCommandType.dpadUp),
size: size,
),
),
Positioned(
left: size,
bottom: 0,
child: _DPadButton(
icon: Icons.arrow_drop_down,
onPressed: () => onCommand(RemoteCommandType.dpadDown),
size: size,
),
),
Positioned(
left: 0,
top: size,
child: _DPadButton(
icon: Icons.arrow_left,
onPressed: () => onCommand(RemoteCommandType.dpadLeft),
size: size,
),
),
Positioned(
right: 0,
top: size,
child: _DPadButton(
icon: Icons.arrow_right,
onPressed: () => onCommand(RemoteCommandType.dpadRight),
size: size,
),
),
Positioned(
left: (size * 3 - centerSize) / 2,
top: (size * 3 - centerSize) / 2,
child: _DPadButton(
icon: Icons.check,
label: 'OK',
onPressed: () => onCommand(RemoteCommandType.select),
size: centerSize,
isPrimary: true,
),
),
],
),
);
}
}
class _DPadButton extends StatelessWidget {
final IconData icon;
final String? label;
final VoidCallback onPressed;
final double size;
final bool isPrimary;
const _DPadButton({
required this.icon,
this.label,
required this.onPressed,
required this.size,
this.isPrimary = false,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: ElevatedButton(
onPressed: () {
HapticFeedback.lightImpact();
onPressed();
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.zero,
shape: const CircleBorder(),
backgroundColor: isPrimary ? Theme.of(context).colorScheme.primary : null,
foregroundColor: isPrimary ? Theme.of(context).colorScheme.onPrimary : null,
),
child: label != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 24),
const SizedBox(height: 2),
Text(label!, style: const TextStyle(fontSize: 10)),
],
)
: Icon(icon, size: 36),
),
);
}
}
class _RemoteButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onPressed;
final double size;
final double iconSize;
const _RemoteButton({
required this.icon,
required this.label,
required this.onPressed,
this.size = 56,
this.iconSize = 24,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
child: FilledButton(
onPressed: () {
HapticFeedback.lightImpact();
onPressed();
},
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: const CircleBorder(),
),
child: Icon(icon, size: iconSize),
),
),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
);
}
}
class _RemoteChip extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onPressed;
const _RemoteChip({
required this.icon,
required this.label,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return ActionChip(
avatar: Icon(icon, size: 18),
label: Text(label),
onPressed: () {
HapticFeedback.lightImpact();
onPressed();
},
);
}
}
class _SearchBottomSheet extends StatefulWidget {
final CompanionRemoteProvider provider;
const _SearchBottomSheet({required this.provider});
@override
State<_SearchBottomSheet> createState() => _SearchBottomSheetState();
}
class _SearchBottomSheetState extends State<_SearchBottomSheet> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit(String text) {
final trimmed = text.trim();
if (trimmed.isNotEmpty) {
widget.provider.sendCommand(RemoteCommandType.search, data: {'query': trimmed});
}
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
left: 16,
right: 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _controller,
autofocus: true,
decoration: InputDecoration(
hintText: 'Search on desktop...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.send),
onPressed: () => _submit(_controller.text),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(100),
),
),
onSubmitted: _submit,
),
const SizedBox(height: 16),
],
),
);
}
}
class _RemoteCard extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onPressed;
const _RemoteCard({
required this.icon,
required this.label,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 100,
height: 100,
child: Card(
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onPressed();
},
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 32),
const SizedBox(height: 8),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}