network addition
This commit is contained in:
parent
3bd713d667
commit
2099d3d64d
27 changed files with 2237 additions and 40 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue