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

749 lines
24 KiB
Dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_text_field.dart';
import '../../models/companion_remote/remote_command.dart';
import '../../models/companion_remote/remote_session.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
import '../../providers/companion_remote_provider.dart';
import '../../utils/platform_detector.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/app_logger.dart';
import '../../utils/dialogs.dart';
import '../../widgets/companion_remote/discovery_view.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/overlay_sheet.dart';
import '../../widgets/pill_input_decoration.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 OverlaySheetHost(
// Close an open sheet on system back instead of popping the screen.
canPop: true,
child: Scaffold(
appBar: AppBar(
title: Text(t.companionRemote.title),
actions: [
Consumer<CompanionRemoteProvider>(
builder: (context, provider, child) {
if (provider.isConnected) {
return IconButton(
icon: const AppIcon(Symbols.link_off_rounded),
onPressed: () async {
final confirmed = await showConfirmDialog(
context,
title: t.common.disconnect,
message: t.companionRemote.remote.disconnectConfirm,
confirmText: t.common.disconnect,
isDestructive: true,
);
if (confirmed && 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: .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: tokens(context).textMuted),
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: .center,
children: [
OutlinedButton(
onPressed: () async {
await provider.cancelReconnect();
},
child: Text(t.common.cancel),
),
const SizedBox(width: 16),
FilledButton(
onPressed: () async {
await provider.retryReconnectNow();
},
child: Text(t.companionRemote.remote.retryNow),
),
],
),
],
),
);
}
if (!provider.isConnected) {
return const DiscoveryView();
}
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>();
OverlaySheetController.of(
context,
).show(showDragHandle: 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: [
AppIcon(Symbols.computer_rounded, color: Theme.of(context).colorScheme.onPrimaryContainer),
const SizedBox(width: 12),
Expanded(
child: Column(
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: const 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 AppIcon(Symbols.navigation_rounded),
),
ButtonSegment(
value: 1,
label: Text(t.companionRemote.remote.tabPlay),
icon: const AppIcon(Symbols.play_arrow_rounded),
),
ButtonSegment(
value: 2,
label: Text(t.companionRemote.remote.tabMore),
icon: const AppIcon(Symbols.flash_on_rounded),
),
],
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: .spaceEvenly,
children: [
_RemoteButton(
icon: Symbols.home_rounded,
label: t.common.home,
onPressed: () => _sendCommand(RemoteCommandType.home),
),
_RemoteButton(
icon: Symbols.arrow_back_rounded,
label: t.common.back,
onPressed: () => _sendCommand(RemoteCommandType.back),
),
_RemoteButton(
icon: Symbols.menu_rounded,
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: Symbols.explore_rounded,
label: t.companionRemote.remote.tabDiscover,
onPressed: () => _sendCommand(RemoteCommandType.tabDiscover),
),
_RemoteChip(
icon: Symbols.video_library_rounded,
label: t.companionRemote.remote.tabLibraries,
onPressed: () => _sendCommand(RemoteCommandType.tabLibraries),
),
_RemoteChip(
icon: Symbols.search_rounded,
label: t.companionRemote.remote.tabSearch,
onPressed: () => _showSearchSheet(switchToSearchTab: true),
),
_RemoteChip(
icon: Symbols.download_rounded,
label: t.companionRemote.remote.tabDownloads,
onPressed: () => _sendCommand(RemoteCommandType.tabDownloads),
),
_RemoteChip(
icon: Symbols.settings_rounded,
label: t.companionRemote.remote.tabSettings,
onPressed: () => _sendCommand(RemoteCommandType.tabSettings),
),
],
),
],
],
);
}
Widget _buildPlaybackTab() {
return Column(
children: [
const SizedBox(height: 16),
Row(
mainAxisAlignment: .center,
children: [
_RemoteButton(
icon: Symbols.skip_previous_rounded,
label: t.companionRemote.remote.previous,
onPressed: () => _sendCommand(RemoteCommandType.previousTrack),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Symbols.play_arrow_rounded,
label: t.companionRemote.remote.playPause,
size: 64,
iconSize: 36,
onPressed: () => _sendCommand(RemoteCommandType.playPause),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Symbols.skip_next_rounded,
label: t.companionRemote.remote.next,
onPressed: () => _sendCommand(RemoteCommandType.nextTrack),
),
],
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: .center,
children: [
_RemoteButton(
icon: Symbols.replay_10_rounded,
label: t.companionRemote.remote.seekBack,
onPressed: () => _sendCommand(RemoteCommandType.seekBackward),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Symbols.stop_rounded,
label: t.companionRemote.remote.stop,
onPressed: () => _sendCommand(RemoteCommandType.stop),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Symbols.forward_10_rounded,
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: .center,
children: [
_RemoteButton(
icon: Symbols.volume_off_rounded,
label: t.common.mute,
onPressed: () => _sendCommand(RemoteCommandType.volumeMute),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Symbols.volume_down_rounded,
label: t.companionRemote.remote.volumeDown,
onPressed: () => _sendCommand(RemoteCommandType.volumeDown),
),
const SizedBox(width: 16),
_RemoteButton(
icon: Symbols.volume_up_rounded,
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: Symbols.search_rounded, label: t.common.search, onPressed: _showSearchSheet),
if (isPlayerActive) ...[
_RemoteCard(
icon: Symbols.fullscreen_rounded,
label: t.companionRemote.remote.fullscreen,
onPressed: () => _sendCommand(RemoteCommandType.fullscreen),
),
_RemoteCard(
icon: Symbols.subtitles_rounded,
label: t.companionRemote.remote.subtitles,
onPressed: () => _sendCommand(RemoteCommandType.subtitles),
),
_RemoteCard(
icon: Symbols.audiotrack_rounded,
label: t.companionRemote.remote.audio,
onPressed: () => _sendCommand(RemoteCommandType.audioTracks),
),
],
],
),
],
);
}
}
class _DPad extends StatelessWidget {
final Function(RemoteCommandType) onCommand;
const _DPad({required this.onCommand});
static const _diameter = 220.0;
static const _centerSize = 80.0;
static const _gapWidth = 3.0;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return SizedBox(
width: _diameter,
height: _diameter,
child: ClipOval(
child: Stack(
children: [
Container(
decoration: BoxDecoration(shape: BoxShape.circle, color: colors.surface),
),
_DPadZone(
startAngle: -135,
icon: Symbols.keyboard_arrow_up_rounded,
onTap: () => onCommand(RemoteCommandType.dpadUp),
),
_DPadZone(
startAngle: -45,
icon: Symbols.keyboard_arrow_right_rounded,
onTap: () => onCommand(RemoteCommandType.dpadRight),
),
_DPadZone(
startAngle: 45,
icon: Symbols.keyboard_arrow_down_rounded,
onTap: () => onCommand(RemoteCommandType.dpadDown),
),
_DPadZone(
startAngle: 135,
icon: Symbols.keyboard_arrow_left_rounded,
onTap: () => onCommand(RemoteCommandType.dpadLeft),
),
Center(child: _DPadCenter(onTap: () => onCommand(RemoteCommandType.select))),
],
),
),
);
}
}
class _DPadZone extends StatefulWidget {
final double startAngle;
final IconData icon;
final VoidCallback onTap;
const _DPadZone({required this.startAngle, required this.icon, required this.onTap});
Alignment get iconAlignment {
final midRad = (startAngle + 45) * pi / 180;
const r = 0.78;
return Alignment(r * cos(midRad), r * sin(midRad));
}
@override
State<_DPadZone> createState() => _DPadZoneState();
}
class _DPadZoneState extends State<_DPadZone> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Positioned.fill(
child: ClipPath(
clipper: _SectorClipper(
startAngle: widget.startAngle,
innerRadius: _DPad._centerSize / 2 + _DPad._gapWidth,
gapWidth: _DPad._gapWidth,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
onTap: () {
HapticFeedback.lightImpact();
widget.onTap();
},
child: AnimatedContainer(
duration: tokens(context).fast,
color: _pressed ? colors.primary.withValues(alpha: 0.8) : colors.primary,
alignment: widget.iconAlignment,
child: AppIcon(widget.icon, size: 28, color: colors.onPrimary),
),
),
),
);
}
}
class _DPadCenter extends StatefulWidget {
final VoidCallback onTap;
const _DPadCenter({required this.onTap});
@override
State<_DPadCenter> createState() => _DPadCenterState();
}
class _DPadCenterState extends State<_DPadCenter> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _pressed = true),
onTapUp: (_) => setState(() => _pressed = false),
onTapCancel: () => setState(() => _pressed = false),
onTap: () {
HapticFeedback.lightImpact();
widget.onTap();
},
child: AnimatedContainer(
duration: tokens(context).fast,
width: _DPad._centerSize,
height: _DPad._centerSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _pressed ? colors.primary.withValues(alpha: 0.8) : colors.primary,
),
child: const SizedBox.shrink(),
),
);
}
}
class _SectorClipper extends CustomClipper<Path> {
final double startAngle;
final double innerRadius;
final double gapWidth;
const _SectorClipper({required this.startAngle, required this.innerRadius, required this.gapWidth});
@override
Path getClip(Size size) {
final center = Offset(size.width / 2, size.height / 2);
final outerRadius = size.width / 2;
final halfGap = gapWidth / 2;
final startRad = startAngle * pi / 180;
final endRad = startRad + pi / 2;
// Use arcsin to compute angular offset at each radius for constant-width gap
final innerOffset = asin(halfGap / innerRadius);
final outerOffset = asin(halfGap / outerRadius);
final innerStart = startRad + innerOffset;
final outerStart = startRad + outerOffset;
final outerEnd = endRad - outerOffset;
final innerEnd = endRad - innerOffset;
return Path()
..moveTo(center.dx + innerRadius * cos(innerStart), center.dy + innerRadius * sin(innerStart))
..lineTo(center.dx + outerRadius * cos(outerStart), center.dy + outerRadius * sin(outerStart))
..arcTo(Rect.fromCircle(center: center, radius: outerRadius), outerStart, outerEnd - outerStart, false)
..lineTo(center.dx + innerRadius * cos(innerEnd), center.dy + innerRadius * sin(innerEnd))
..arcTo(Rect.fromCircle(center: center, radius: innerRadius), innerEnd, innerStart - innerEnd, false)
..close();
}
@override
bool shouldReclip(covariant _SectorClipper oldClipper) =>
startAngle != oldClipper.startAngle || innerRadius != oldClipper.innerRadius || gapWidth != oldClipper.gapWidth;
}
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: .min,
children: [
SizedBox(
width: size,
height: size,
child: FilledButton(
onPressed: () {
HapticFeedback.lightImpact();
onPressed();
},
style: FilledButton.styleFrom(padding: .zero, shape: const CircleBorder()),
child: AppIcon(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 FilledButton.icon(
onPressed: () {
HapticFeedback.lightImpact();
onPressed();
},
icon: AppIcon(icon, size: 18),
label: Text(label),
);
}
}
class _SearchBottomSheet extends StatefulWidget {
final CompanionRemoteProvider provider;
const _SearchBottomSheet({required this.provider});
@override
State<_SearchBottomSheet> createState() => _SearchBottomSheetState();
}
class _SearchBottomSheetState extends State<_SearchBottomSheet> with ControllerDisposerMixin {
late final _controller = createTextEditingController();
void _submit(String text) {
final trimmed = text.trim();
if (trimmed.isNotEmpty) {
widget.provider.sendCommand(RemoteCommandType.search, data: {'query': trimmed});
}
OverlaySheetController.of(context).close();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom, left: 16, right: 16, top: 16),
child: Column(
mainAxisSize: .min,
children: [
FocusableTextField(
controller: _controller,
autofocus: true,
decoration: pillInputDecoration(
context,
hintText: t.companionRemote.remote.searchHint,
prefixIcon: const AppIcon(Symbols.search_rounded),
suffixIcon: IconButton(
icon: const AppIcon(Symbols.send_rounded),
onPressed: () => _submit(_controller.text),
),
),
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: .center,
children: [
AppIcon(icon, size: 32),
const SizedBox(height: 8),
Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center),
],
),
),
),
);
}
}