228 lines
6.9 KiB
Dart
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)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|