network addition
This commit is contained in:
parent
3bd713d667
commit
2099d3d64d
27 changed files with 2237 additions and 40 deletions
228
lib/screens/devices_sheet.dart
Normal file
228
lib/screens/devices_sheet.dart
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
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) => const Padding(
|
||||
padding: 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: const TextStyle(color: TimbreColors.dimmed)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue