network addition

This commit is contained in:
Forrest 2026-08-04 16:39:18 -04:00
parent 3bd713d667
commit 2099d3d64d
27 changed files with 2237 additions and 40 deletions

View file

@ -339,7 +339,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
Widget build(BuildContext context) {
final index = ref.watch(libraryIndexProvider);
final visible = ref.watch(visibleTracksProvider);
final playback = ref.read(playbackProvider.notifier);
final playback = ref.read(playbackCommandsProvider);
final client = ref.watch(subsonicClientProvider);
final Widget body;
@ -564,7 +564,7 @@ class AlbumScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(albumProvider(id));
final downloads = ref.watch(downloadManagerProvider);
final playback = ref.read(playbackProvider.notifier);
final playback = ref.read(playbackCommandsProvider);
final songs = album.valueOrNull?.songs ?? const <Song>[];
return _DetailScaffold(

View 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)),
),
],
),
);
}
}

View file

@ -15,7 +15,7 @@ class DownloadsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final downloads = ref.watch(downloadManagerProvider);
final controller = ref.read(downloadManagerProvider.notifier);
final playback = ref.read(playbackProvider.notifier);
final playback = ref.read(playbackCommandsProvider);
final active = downloads.byId.values.where((d) => d.isActive).toList();
final completed = downloads.completed;

View file

@ -46,13 +46,13 @@ class FavoritesScreen extends ConsumerWidget {
title: s.songs[i].title ?? 'Untitled',
trailing: s.songs[i].artist,
onTap: () => ref
.read(playbackProvider.notifier)
.read(playbackCommandsProvider)
.playSongs(s.songs, startIndex: i),
onPlayNext: () => ref
.read(playbackProvider.notifier)
.read(playbackCommandsProvider)
.playNext(s.songs[i]),
onAddToQueue: () => ref
.read(playbackProvider.notifier)
.read(playbackCommandsProvider)
.addToQueue(s.songs[i]),
),
],

View file

@ -97,7 +97,7 @@ class _HeroCard extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary;
final client = ref.watch(subsonicClientProvider);
final current = ref.watch(playbackProvider.select((s) => s.current));
final current = ref.watch(activePlaybackProvider.select((s) => s.current));
// Fall back to the most recent track so the hero is useful before playback.
final recent = ref.watch(recentSongsProvider);
@ -136,7 +136,7 @@ class _HeroCard extends ConsumerWidget {
if (hasCurrent) {
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
} else if (fallback != null) {
ref.read(playbackProvider.notifier).playSongs([fallback.toSong()]);
ref.read(playbackCommandsProvider).playSongs([fallback.toSong()]);
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
}
},
@ -210,7 +210,7 @@ class _HeroProgress extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final progress = ref.watch(playbackProvider.select((s) => s.progress));
final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
return BlockProgressBar(progress: progress, cells: 32, height: 6);
}
}

View file

@ -7,12 +7,14 @@ import '../layout/breakpoints.dart';
import '../playback/playback_engine.dart';
import '../settings/settings_store.dart';
import '../state/providers.dart';
import '../state/remote_providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/block_progress_bar.dart';
import '../widgets/cassette_view.dart';
import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart';
import 'devices_sheet.dart';
/// Now Playing tab — album art + info strip + transport, bound to the live
/// playback engine. On compact screens the top region shows the full-size album
@ -44,18 +46,18 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
/// [FavoritesController.seedSong].
void _seedFavorites() {
if (!mounted) return;
final song = ref.read(playbackProvider).current;
final song = ref.read(activePlaybackProvider).current;
if (song != null) ref.read(favoritesProvider.notifier).seedSong(song);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(playbackProvider);
final state = ref.watch(activePlaybackProvider);
final accent = Theme.of(context).colorScheme.primary;
final current = state.current;
// Re-seed the favorites store whenever the track changes.
ref.listen(playbackProvider.select((s) => s.current?.id),
ref.listen(activePlaybackProvider.select((s) => s.current?.id),
(_, _) => _seedFavorites());
if (current == null) {
@ -65,6 +67,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
);
}
// Cross-device control is offered next to the queue toggle (compact) or
// beneath the transport (wide); hidden where the platform can't host/browse.
final remoteSupported =
ref.watch(remoteControlProvider.select((s) => s.supported));
// Everything below the art region — shared by both layouts. The queue
// toggle is deliberately excluded: it belongs only to the compact layout
// (where art and queue share one region), so it's appended separately.
@ -98,7 +105,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
),
const SizedBox(width: TimbreSpacing.sm),
TextButton(
onPressed: () => ref.read(playbackProvider.notifier).retry(),
onPressed: () => ref.read(playbackCommandsProvider).retry(),
child: Text('Retry', style: TextStyle(color: accent)),
),
],
@ -132,6 +139,10 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
const Expanded(child: _FittedArt()),
const SizedBox(height: TimbreSpacing.lg),
...controls,
if (remoteSupported) ...[
const SizedBox(height: TimbreSpacing.sm),
const Center(child: _RemoteButton()),
],
],
),
),
@ -142,13 +153,25 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
);
}
// Compact: art and queue share the top region, swapped by the toggle.
// Compact: art and queue share the top region, swapped by the toggle. The
// devices button sits beside the toggle so both bottom affordances share one
// centered row.
final queueToggle = _QueueToggle(
showQueue: _showQueue,
queueLength: state.queue.length,
accent: accent,
onTap: () => setState(() => _showQueue = !_showQueue),
);
final bottomBar = remoteSupported
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
queueToggle,
const SizedBox(width: TimbreSpacing.sm),
const _RemoteButton(),
],
)
: queueToggle;
return Padding(
padding: padding,
@ -161,7 +184,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
const SizedBox(height: TimbreSpacing.lg),
...controls,
const SizedBox(height: TimbreSpacing.sm),
queueToggle,
bottomBar,
],
)
// Art absorbs the leftover height, capped to a square, so the
@ -174,7 +197,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
const SizedBox(height: TimbreSpacing.lg),
...controls,
const SizedBox(height: TimbreSpacing.sm),
queueToggle,
bottomBar,
],
),
);
@ -275,12 +298,12 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
@override
Widget build(BuildContext context) {
final state = ref.watch(playbackProvider);
final state = ref.watch(activePlaybackProvider);
final accent = Theme.of(context).colorScheme.primary;
// Follow the playing track as it advances (or as shuffle reorders things).
ref.listen<int?>(
playbackProvider.select((s) => s.currentIndex),
activePlaybackProvider.select((s) => s.currentIndex),
(_, next) => _scrollToIndex(next),
);
// Jump to the current track the first time the queue is populated.
@ -302,7 +325,7 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
// index — exactly the convention reorderQueue (and just_audio's
// moveAudioSource) expects — so no off-by-one adjustment is needed.
onReorderItem: (oldIndex, newIndex) =>
ref.read(playbackProvider.notifier).reorderQueue(oldIndex, newIndex),
ref.read(playbackCommandsProvider).reorderQueue(oldIndex, newIndex),
itemBuilder: (context, i) {
final song = state.queue[i];
final current = state.currentIndex;
@ -317,7 +340,7 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
// collide and ReorderableListView requires unique keys. These rows
// are stateless, so keying by index leaks no state.
key: ValueKey(i),
onTap: () => ref.read(playbackProvider.notifier).jumpTo(i),
onTap: () => ref.read(playbackCommandsProvider).jumpTo(i),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg,
@ -346,7 +369,7 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
style: const TextStyle(color: TimbreColors.dimmed)),
InkWell(
onTap: () =>
ref.read(playbackProvider.notifier).removeAt(i),
ref.read(playbackCommandsProvider).removeAt(i),
customBorder: const CircleBorder(),
child: const SizedBox(
width: TimbreSpacing.minTouchTarget,
@ -403,7 +426,7 @@ class _AlbumArtPanel extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final coverArt =
ref.watch(playbackProvider.select((s) => s.current?.coverArt));
ref.watch(activePlaybackProvider.select((s) => s.current?.coverArt));
final client = ref.watch(subsonicClientProvider);
final cassette =
ref.watch(settingsProvider.select((s) => s.nowPlayingCassette));
@ -525,6 +548,53 @@ class _FavRating extends ConsumerWidget {
}
}
/// Cross-device control affordance sitting beside the queue toggle
/// (updates-features.md #3). Styled to match [_QueueToggle]: when controlling
/// another device it shows the device name in the accent colour; otherwise a
/// dim "Devices" entry point. Tapping opens the devices sheet (which also holds
/// the "This device" / detach action).
class _RemoteButton extends ConsumerWidget {
const _RemoteButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final rc = ref.watch(remoteControlProvider);
final accent = Theme.of(context).colorScheme.primary;
final attached = rc.isAttached;
final label = attached ? (rc.attachedDevice?.name ?? 'Remote') : 'Devices';
return InkWell(
onTap: () => showDevicesSheet(context),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.sm,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
attached ? Icons.cast_connected : Icons.cast,
size: 16,
color: attached ? accent : TimbreColors.dimmed,
),
const SizedBox(width: TimbreSpacing.sm),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: attached ? accent : TimbreColors.foreground,
),
),
),
],
),
),
);
}
}
class _InfoStrip extends StatelessWidget {
const _InfoStrip({required this.song, required this.accent});
@ -562,10 +632,10 @@ class _NowPlayingProgress extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final position = ref.watch(playbackProvider.select((s) => s.position));
final position = ref.watch(activePlaybackProvider.select((s) => s.position));
final duration =
ref.watch(playbackProvider.select((s) => s.effectiveDuration));
final progress = ref.watch(playbackProvider.select((s) => s.progress));
ref.watch(activePlaybackProvider.select((s) => s.effectiveDuration));
final progress = ref.watch(activePlaybackProvider.select((s) => s.progress));
return Row(
children: [
Text(_fmtDur(position),
@ -592,7 +662,7 @@ class _Transport extends StatelessWidget {
@override
Widget build(BuildContext context) {
final controller = ref.read(playbackProvider.notifier);
final controller = ref.read(playbackCommandsProvider);
final loopIcon = switch (state.loop) {
LoopMode.one => Icons.repeat_one,
_ => Icons.repeat,

View file

@ -325,7 +325,7 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
s.playlists.where((p) => p.id == widget.id).firstOrNull));
final connected = ref.watch(subsonicClientProvider) != null;
final me = ref.watch(currentUsernameProvider);
final playback = ref.read(playbackProvider.notifier);
final playback = ref.read(playbackCommandsProvider);
final songs = detail?.songs ?? const <Song>[];
// Ownership drives which sharing affordance shows: owner → share toggle;

View file

@ -102,13 +102,13 @@ class _Results extends ConsumerWidget {
title: r.songs[i].title ?? 'Untitled',
trailing: r.songs[i].artist,
onTap: () => ref
.read(playbackProvider.notifier)
.read(playbackCommandsProvider)
.playSongs(r.songs, startIndex: i),
onPlayNext: () => ref
.read(playbackProvider.notifier)
.read(playbackCommandsProvider)
.playNext(r.songs[i]),
onAddToQueue: () => ref
.read(playbackProvider.notifier)
.read(playbackCommandsProvider)
.addToQueue(r.songs[i]),
),
],