mobile-music/lib/screens/devices_sheet.dart
2026-08-05 21:57:06 -04:00

228 lines
6.9 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../remote/discovery.dart';
import '../remote/remote_session.dart';
import '../state/remote_providers.dart';
import '../theme/tokens.dart';
/// Open the cross-device control sheet: pick a same-Wi-Fi Timbre device to
/// control, or detach back to local playback (updates-features.md #3).
Future<void> showDevicesSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
builder: (_) => const _DevicesSheet(),
);
}
class _DevicesSheet extends ConsumerStatefulWidget {
const _DevicesSheet();
@override
ConsumerState<_DevicesSheet> createState() => _DevicesSheetState();
}
class _DevicesSheetState extends ConsumerState<_DevicesSheet> {
@override
void initState() {
super.initState();
// Browse only while the sheet is open — discovery is comparatively costly.
Future.microtask(
() => ref.read(remoteControlProvider.notifier).startBrowsing());
}
@override
void dispose() {
ref.read(remoteControlProvider.notifier).stopBrowsing();
super.dispose();
}
@override
Widget build(BuildContext context) {
final rc = ref.watch(remoteControlProvider);
final accent = Theme.of(context).colorScheme.primary;
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Text('Devices',
style:
TextStyle(color: accent, fontWeight: FontWeight.w700)),
),
const SizedBox(height: TimbreSpacing.md),
if (!rc.supported)
const _Note(
"Cross-device control isn't available on this platform.")
else ...[
// "Play here" row — active when currently controlling a remote.
_LocalRow(
active: !rc.isAttached,
onTap: rc.isAttached
? () => ref.read(remoteControlProvider.notifier).detach()
: null,
),
const _Divider(),
for (final d in rc.devices)
_DeviceRow(
device: d,
active: rc.attachedDevice?.id == d.id,
connecting: rc.attachedDevice?.id == d.id &&
rc.status == RemoteConnStatus.connecting,
onTap: () =>
ref.read(remoteControlProvider.notifier).attach(d),
),
if (rc.devices.isEmpty)
const _Note('Searching for devices on your Wi-Fi…',
spinner: true),
if (rc.status == RemoteConnStatus.denied)
const _Note(
'That device refused the connection (different account?).'),
],
],
),
),
);
}
}
/// The "play on this device" (local) row, shown selected when not attached.
class _LocalRow extends StatelessWidget {
const _LocalRow({required this.active, this.onTap});
final bool active;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Row(
children: [
Icon(Icons.smartphone,
size: 16,
color: active ? accent : TimbreColors.foreground),
const SizedBox(width: TimbreSpacing.md),
Expanded(
child: Text('This device',
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
)),
),
if (active)
Text('●', style: TextStyle(color: accent)),
],
),
),
);
}
}
class _DeviceRow extends StatelessWidget {
const _DeviceRow({
required this.device,
required this.active,
required this.connecting,
required this.onTap,
});
final DiscoveredDevice device;
final bool active;
final bool connecting;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Row(
children: [
Icon(Icons.cast,
size: 16,
color: active ? accent : TimbreColors.foreground),
const SizedBox(width: TimbreSpacing.md),
Expanded(
child: Text(
device.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: TimbreColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
),
if (connecting)
const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (active)
Text('●', style: TextStyle(color: accent)),
],
),
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.sm),
child: Divider(color: TimbreColors.border, height: 1),
);
}
class _Note extends StatelessWidget {
const _Note(this.text, {this.spinner = false});
final String text;
final bool spinner;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.xl, vertical: TimbreSpacing.md),
child: Row(
children: [
if (spinner) ...[
const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: TimbreSpacing.md),
],
Flexible(
child: Text(text,
style: TextStyle(color: TimbreColors.dimmed)),
),
],
),
);
}
}