Files
plezy/lib/screens/companion_remote/mobile_remote_screen.dart
T

695 lines
22 KiB
Dart

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 '../../i18n/strings.g.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: Text(t.companionRemote.title),
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: Text(t.common.disconnect),
content: Text(t.companionRemote.remote.disconnectConfirm),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)),
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.disconnect)),
],
),
);
if (confirmed == true && context.mounted) {
await context.read<CompanionRemoteProvider>().leaveSession();
}
},
tooltip: t.common.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(t.companionRemote.remote.reconnecting, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(
t.companionRemote.remote.attemptOf(current: provider.reconnectAttempts),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton(onPressed: () => provider.cancelReconnect(), child: Text(t.common.cancel)),
const SizedBox(width: 16),
FilledButton(
onPressed: () => provider.retryReconnectNow(),
child: Text(t.companionRemote.remote.retryNow),
),
],
),
],
),
);
}
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 ?? t.companionRemote.remote.connectionError
: t.companionRemote.remote.notConnected,
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: Text(t.companionRemote.connectToDevice),
),
],
),
);
}
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();
appLogger.d('MobileRemoteScreen: Sending command: $type');
final provider = context.read<CompanionRemoteProvider>();
provider.sendCommand(type);
}
@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: ListView(
padding: const EdgeInsets.all(16),
children: [
SegmentedButton<int>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: 0,
label: Text(t.companionRemote.remote.tabRemote),
icon: const Icon(Icons.navigation),
),
ButtonSegment(
value: 1,
label: Text(t.companionRemote.remote.tabPlay),
icon: const Icon(Icons.play_arrow),
),
ButtonSegment(
value: 2,
label: Text(t.companionRemote.remote.tabMore),
icon: const 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() {
final isPlayerActive = context.watch<CompanionRemoteProvider>().isPlayerActive;
return Column(
children: [
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_RemoteButton(
icon: Icons.home,
label: t.common.home,
onPressed: () => _sendCommand(RemoteCommandType.home),
),
_RemoteButton(
icon: Icons.arrow_back,
label: t.common.back,
onPressed: () => _sendCommand(RemoteCommandType.back),
),
_RemoteButton(
icon: Icons.menu,
label: t.companionRemote.remote.menu,
onPressed: () => _sendCommand(RemoteCommandType.contextMenu),
),
],
),
const SizedBox(height: 32),
Center(child: _DPad(onCommand: _sendCommand)),
if (!isPlayerActive) ...[
const SizedBox(height: 32),
Text(t.companionRemote.remote.tabNavigation, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
_RemoteChip(
icon: Icons.explore,
label: t.companionRemote.remote.tabDiscover,
onPressed: () => _sendCommand(RemoteCommandType.tabDiscover),
),
_RemoteChip(
icon: Icons.video_library,
label: t.companionRemote.remote.tabLibraries,
onPressed: () => _sendCommand(RemoteCommandType.tabLibraries),
),
_RemoteChip(
icon: Icons.search,
label: t.companionRemote.remote.tabSearch,
onPressed: () => _showSearchSheet(switchToSearchTab: true),
),
_RemoteChip(
icon: Icons.download,
label: t.companionRemote.remote.tabDownloads,
onPressed: () => _sendCommand(RemoteCommandType.tabDownloads),
),
_RemoteChip(
icon: Icons.settings,
label: t.companionRemote.remote.tabSettings,
onPressed: () => _sendCommand(RemoteCommandType.tabSettings),
),
],
),
],
],
);
}
Widget _buildPlaybackTab() {
return Column(
children: [
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_RemoteButton(
icon: Icons.skip_previous,
label: t.companionRemote.remote.previous,
onPressed: () => _sendCommand(RemoteCommandType.previousTrack),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.play_arrow,
label: t.companionRemote.remote.playPause,
size: 64,
iconSize: 36,
onPressed: () => _sendCommand(RemoteCommandType.playPause),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.skip_next,
label: t.companionRemote.remote.next,
onPressed: () => _sendCommand(RemoteCommandType.nextTrack),
),
],
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_RemoteButton(
icon: Icons.replay_10,
label: t.companionRemote.remote.seekBack,
onPressed: () => _sendCommand(RemoteCommandType.seekBackward),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.stop,
label: t.companionRemote.remote.stop,
onPressed: () => _sendCommand(RemoteCommandType.stop),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.forward_10,
label: t.companionRemote.remote.seekForward,
onPressed: () => _sendCommand(RemoteCommandType.seekForward),
),
],
),
const SizedBox(height: 32),
Text(t.companionRemote.remote.volume, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_RemoteButton(
icon: Icons.volume_off,
label: t.common.mute,
onPressed: () => _sendCommand(RemoteCommandType.volumeMute),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.volume_down,
label: t.companionRemote.remote.volumeDown,
onPressed: () => _sendCommand(RemoteCommandType.volumeDown),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Icons.volume_up,
label: t.companionRemote.remote.volumeUp,
onPressed: () => _sendCommand(RemoteCommandType.volumeUp),
),
],
),
],
);
}
Widget _buildQuickActionsTab() {
final isPlayerActive = context.watch<CompanionRemoteProvider>().isPlayerActive;
return Column(
children: [
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
if (!isPlayerActive) _RemoteCard(icon: Icons.search, label: t.common.search, onPressed: _showSearchSheet),
if (isPlayerActive) ...[
_RemoteCard(
icon: Icons.fullscreen,
label: t.companionRemote.remote.fullscreen,
onPressed: () => _sendCommand(RemoteCommandType.fullscreen),
),
_RemoteCard(
icon: Icons.subtitles,
label: t.companionRemote.remote.subtitles,
onPressed: () => _sendCommand(RemoteCommandType.subtitles),
),
_RemoteCard(
icon: Icons.audiotrack,
label: t.companionRemote.remote.audio,
onPressed: () => _sendCommand(RemoteCommandType.audioTracks),
),
],
],
),
],
);
}
}
class _DPad extends StatelessWidget {
final Function(RemoteCommandType) onCommand;
const _DPad({required this.onCommand});
@override
Widget build(BuildContext context) {
const size = 72.0;
const gap = 8.0;
const total = size * 3 + gap * 2;
return SizedBox(
width: total,
height: total,
child: Stack(
children: [
Positioned(
left: size + gap,
top: 0,
child: _DPadButton(
icon: Icons.arrow_drop_up,
onPressed: () => onCommand(RemoteCommandType.dpadUp),
size: size,
),
),
Positioned(
left: size + gap,
bottom: 0,
child: _DPadButton(
icon: Icons.arrow_drop_down,
onPressed: () => onCommand(RemoteCommandType.dpadDown),
size: size,
),
),
Positioned(
left: 0,
top: size + gap,
child: _DPadButton(
icon: Icons.arrow_left,
onPressed: () => onCommand(RemoteCommandType.dpadLeft),
size: size,
),
),
Positioned(
right: 0,
top: size + gap,
child: _DPadButton(
icon: Icons.arrow_right,
onPressed: () => onCommand(RemoteCommandType.dpadRight),
size: size,
),
),
Positioned(
left: size + gap,
top: size + gap,
child: _DPadButton(
icon: Icons.check,
label: t.common.ok,
onPressed: () => onCommand(RemoteCommandType.select),
size: size,
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: t.companionRemote.remote.searchHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(icon: const Icon(Icons.send), onPressed: () => _submit(_controller.text)),
border: OutlineInputBorder(borderRadius: const BorderRadius.all(Radius.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: const BorderRadius.all(Radius.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),
],
),
),
),
);
}
}