import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart' show LoopMode; import '../downloads/download_manager.dart'; 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 /// art by default and swaps to the queue when toggled. On wide screens /// (>= [kWideBreakpoint], i.e. tablet landscape) it splits into two panes: the /// art + controls on the left and a permanently-visible queue on the right, so /// the queue toggle is dropped. class NowPlayingScreen extends ConsumerStatefulWidget { const NowPlayingScreen({super.key}); @override ConsumerState createState() => _NowPlayingScreenState(); } class _NowPlayingScreenState extends ConsumerState { bool _showQueue = false; @override void initState() { super.initState(); // Seed the already-current track once the first frame is up (the ref.listen // below only fires on *changes*, so it would miss the launch track). WidgetsBinding.instance.addPostFrameCallback((_) => _seedFavorites()); } /// Reconcile the current song's own `starred` / `userRating` metadata into /// the favorites store, so a previously-favorited track shows a filled heart /// even when it was surfaced outside the Favorites tab. See /// [FavoritesController.seedSong]. void _seedFavorites() { if (!mounted) return; final song = ref.read(activePlaybackProvider).current; if (song != null) ref.read(favoritesProvider.notifier).seedSong(song); } @override Widget build(BuildContext context) { 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(activePlaybackProvider.select((s) => s.current?.id), (_, _) => _seedFavorites()); if (current == null) { return const Center( child: Text('Nothing playing.', style: TextStyle(color: TimbreColors.dimmed)), ); } // 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. final controls = [ const _NowPlayingProgress(), const SizedBox(height: TimbreSpacing.md), _InfoStrip(song: current, accent: accent), const SizedBox(height: TimbreSpacing.sm), _FavRating(song: current), const SizedBox(height: TimbreSpacing.xs), _Transport(state: state, ref: ref, accent: accent), if (!state.supported) const Padding( padding: EdgeInsets.only(top: TimbreSpacing.sm), child: Text( 'Audio output unavailable on this platform — test on Android/iOS.', style: TextStyle(color: TimbreColors.dimmed, fontSize: 11), ), ), if (state.error == PlaybackError.offline) Padding( padding: const EdgeInsets.only(top: TimbreSpacing.sm), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Flexible( child: Text( "Can't reach the server — playback paused.", style: TextStyle(color: Color(0xFFE06C75), fontSize: 11), ), ), const SizedBox(width: TimbreSpacing.sm), TextButton( onPressed: () => ref.read(playbackCommandsProvider).retry(), child: Text('Retry', style: TextStyle(color: accent)), ), ], ), ), ]; const padding = EdgeInsets.fromLTRB( TimbreSpacing.lg, TimbreSpacing.xl, TimbreSpacing.lg, TimbreSpacing.lg, ); // Wide (tablet landscape): art + controls on the left, always-visible queue // on the right. The left column reuses the same scroll-view trick as the // compact art view so the square art stays width-bound within its pane. if (isWideLayout(context)) { return Padding( padding: padding, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( flex: 5, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Art takes the leftover height and is capped to a square, so // the controls below (esp. the transport row) stay on screen. const Expanded(child: _FittedArt()), const SizedBox(height: TimbreSpacing.lg), ...controls, if (remoteSupported) ...[ const SizedBox(height: TimbreSpacing.sm), const Center(child: _RemoteButton()), ], ], ), ), const SizedBox(width: TimbreSpacing.xl), const Expanded(flex: 4, child: _QueuePanel()), ], ), ); } // 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, child: _showQueue // Queue takes over the flexible region and scrolls internally. ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Expanded(child: _QueuePanel()), const SizedBox(height: TimbreSpacing.lg), ...controls, const SizedBox(height: TimbreSpacing.sm), bottomBar, ], ) // Art absorbs the leftover height, capped to a square, so the // transport and queue toggle beneath it always stay on screen // instead of being pushed below the fold by a full-width square. : Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Expanded(child: _FittedArt()), const SizedBox(height: TimbreSpacing.lg), ...controls, const SizedBox(height: TimbreSpacing.sm), bottomBar, ], ), ); } } /// Text toggle that swaps the top region between album art and the queue. /// Styled like the app's other "↻ re-roll" / "↻ refresh" affordances. class _QueueToggle extends StatelessWidget { const _QueueToggle({ required this.showQueue, required this.queueLength, required this.accent, required this.onTap, }); final bool showQueue; final int queueLength; final Color accent; final VoidCallback onTap; @override Widget build(BuildContext context) { return Center( child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric( horizontal: TimbreSpacing.lg, vertical: TimbreSpacing.sm, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( showQueue ? Icons.album_outlined : Icons.queue_music, size: 16, color: showQueue ? accent : TimbreColors.dimmed, ), const SizedBox(width: TimbreSpacing.sm), Text( showQueue ? 'Album art' : 'Queue ($queueLength)', style: TextStyle( color: showQueue ? accent : TimbreColors.foreground, ), ), ], ), ), ), ); } } /// The play queue with reorder-free remove controls. Fills whichever space the /// top region gives it. Because the engine keeps `state.queue` in true play /// order (even when shuffled), rows read top-to-bottom as history → now /// playing → up next: already-played rows are dimmed and the list auto-scrolls /// to keep the current track at the top as playback advances. class _QueuePanel extends ConsumerStatefulWidget { const _QueuePanel(); @override ConsumerState<_QueuePanel> createState() => _QueuePanelState(); } class _QueuePanelState extends ConsumerState<_QueuePanel> { /// Fixed row height: a [TimbreSpacing.minTouchTarget] tall remove button plus /// the [TimbreSpacing.xs] vertical padding above and below it. Pinning the /// extent lets us scroll to a row by index without measuring. static const double _rowExtent = TimbreSpacing.minTouchTarget + TimbreSpacing.xs * 2; final ScrollController _controller = ScrollController(); bool _didInitialScroll = false; @override void dispose() { _controller.dispose(); super.dispose(); } /// Bring the row at [index] to the top of the viewport (clamped), after the /// current frame so the list has laid out. void _scrollToIndex(int? index) { if (index == null || index < 0) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!_controller.hasClients) return; final target = (index * _rowExtent).clamp(0.0, _controller.position.maxScrollExtent); _controller.animateTo( target, duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, ); }); } @override Widget build(BuildContext context) { 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( activePlaybackProvider.select((s) => s.currentIndex), (_, next) => _scrollToIndex(next), ); // Jump to the current track the first time the queue is populated. if (!_didInitialScroll && state.queue.isNotEmpty) { _didInitialScroll = true; _scrollToIndex(state.currentIndex); } return HairlinePanel( title: 'Queue', trailing: '(${state.queue.length})', padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md), child: ReorderableListView.builder( scrollController: _controller, padding: EdgeInsets.zero, itemExtent: _rowExtent, itemCount: state.queue.length, // onReorderItem already reports newIndex as the post-removal target // index — exactly the convention reorderQueue (and just_audio's // moveAudioSource) expects — so no off-by-one adjustment is needed. onReorderItem: (oldIndex, newIndex) => ref.read(playbackCommandsProvider).reorderQueue(oldIndex, newIndex), itemBuilder: (context, i) { final song = state.queue[i]; final current = state.currentIndex; final isCurrent = i == current; final isPast = current != null && i < current; final titleColor = isCurrent ? accent : (isPast ? TimbreColors.dimmed : TimbreColors.foreground); return InkWell( // Position-based key: the same Song instance can legitimately sit in // the queue twice (e.g. added twice), so an identity key would // collide and ReorderableListView requires unique keys. These rows // are stateless, so keying by index leaks no state. key: ValueKey(i), onTap: () => ref.read(playbackCommandsProvider).jumpTo(i), child: Padding( padding: const EdgeInsets.symmetric( horizontal: TimbreSpacing.lg, vertical: TimbreSpacing.xs, ), child: Row( children: [ SizedBox( // Wide enough for a 4-digit index (2k+ queues); single-line // + no-wrap so a long number can't spill onto a second line // inside the fixed-height row (iPad landscape, 1000+). width: 40, child: Text( '${i + 1}', maxLines: 1, softWrap: false, overflow: TextOverflow.clip, style: const TextStyle(color: TimbreColors.dimmed), ), ), Expanded( child: Text( song.title ?? 'Untitled', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: titleColor, fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w400, ), ), ), Text(_fmt(song.duration), style: const TextStyle(color: TimbreColors.dimmed)), InkWell( onTap: () => ref.read(playbackCommandsProvider).removeAt(i), customBorder: const CircleBorder(), child: const SizedBox( width: TimbreSpacing.minTouchTarget, height: TimbreSpacing.minTouchTarget, child: Icon(Icons.close, size: 18, color: TimbreColors.dimmed), ), ), ], ), ), ); }, ), ); } } /// The album-art panel sized to the largest square that fits the space it's /// given, top-aligned so the controls beneath it always stay on screen. When /// height is unbounded (e.g. inside a scroll view) it falls back to the full /// available width. This is what keeps the transport from being pushed below /// the fold when the art region would otherwise render a giant full-width /// square. class _FittedArt extends StatelessWidget { const _FittedArt(); @override Widget build(BuildContext context) { return Align( alignment: Alignment.topCenter, child: LayoutBuilder( builder: (context, c) { final side = c.maxHeight.isFinite ? c.maxHeight.clamp(0.0, c.maxWidth) : c.maxWidth; return SizedBox( width: side, height: side, child: const _AlbumArtPanel(), ); }, ), ); } } /// Album art isolated into its own `const` widget that watches only the /// current track's cover art — so position-tick rebuilds of the parent don't /// touch it. `gaplessPlayback` + a URL-keyed element keep the previous frame /// on screen until a genuinely new image is ready (no flashing). class _AlbumArtPanel extends ConsumerWidget { const _AlbumArtPanel(); @override Widget build(BuildContext context, WidgetRef ref) { final coverArt = ref.watch(activePlaybackProvider.select((s) => s.current?.coverArt)); final client = ref.watch(subsonicClientProvider); final cassette = ref.watch(settingsProvider.select((s) => s.nowPlayingCassette)); final artUri = (client != null && coverArt != null) ? client.coverArtUri(coverArt, size: 512).toString() : null; return HairlinePanel( title: cassette ? 'Cassette' : 'Album Art', padding: const EdgeInsets.all(TimbreSpacing.md), child: cassette ? Center(child: CassetteView(artUri: artUri)) : AspectRatio( aspectRatio: 1, child: ColoredBox( color: TimbreColors.surface, child: artUri != null ? Image.network( artUri, key: ValueKey(artUri), fit: BoxFit.cover, gaplessPlayback: true, errorBuilder: (_, _, _) => const _ArtFallback(), ) : const _ArtFallback(), ), ), ); } } /// Favorite (star) + 1–5 rating for the current track. Watches favorites only, /// so it updates on star/rating changes independent of position ticks. class _FavRating extends ConsumerWidget { const _FavRating({required this.song}); final Song song; @override Widget build(BuildContext context, WidgetRef ref) { final fav = ref.watch(favoritesProvider); final accent = Theme.of(context).colorScheme.primary; final starred = fav.isSongStarred(song.id); final rating = fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0); final downloadStatus = ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status)); final isDownloaded = downloadStatus == DownloadStatus.done; final isDownloading = downloadStatus == DownloadStatus.queued || downloadStatus == DownloadStatus.downloading; return Row( children: [ InkWell( onTap: () => ref.read(favoritesProvider.notifier).toggleSong(song), customBorder: const CircleBorder(), child: Padding( padding: const EdgeInsets.all(TimbreSpacing.sm), child: Icon( starred ? Icons.favorite : Icons.favorite_border, color: starred ? accent : TimbreColors.dimmed, size: 22, ), ), ), const SizedBox(width: TimbreSpacing.md), for (int i = 1; i <= 5; i++) GestureDetector( onTap: () => ref .read(favoritesProvider.notifier) .rateSong(song.id, i == rating ? 0 : i), child: Padding( padding: const EdgeInsets.all(2), child: Icon( i <= rating ? Icons.star : Icons.star_border, size: 18, color: i <= rating ? accent : TimbreColors.dimmed, ), ), ), const Spacer(), InkWell( onTap: () => showAddToPlaylistSheet(context, songs: [song]), customBorder: const CircleBorder(), child: const Padding( padding: EdgeInsets.all(TimbreSpacing.sm), child: Icon(Icons.playlist_add, size: 22, color: TimbreColors.dimmed), ), ), InkWell( onTap: isDownloading ? null : () { final dm = ref.read(downloadManagerProvider.notifier); isDownloaded ? dm.remove(song.id) : dm.download(song); }, customBorder: const CircleBorder(), child: Padding( padding: const EdgeInsets.all(TimbreSpacing.sm), child: isDownloading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon( isDownloaded ? Icons.download_done : Icons.download_outlined, size: 22, color: isDownloaded ? accent : TimbreColors.dimmed, ), ), ), ], ); } } /// 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}); final Song song; final Color accent; @override Widget build(BuildContext context) { final album = [ if (song.album != null) song.album, if (song.year != null) '${song.year}', ].join(' · '); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(song.title ?? 'Untitled', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: accent, fontWeight: FontWeight.w700)), Text(song.artist ?? 'Unknown artist', style: const TextStyle(color: TimbreColors.foreground)), if (album.isNotEmpty) Text(album, style: const TextStyle(color: TimbreColors.dimmed)), ], ); } } /// Chunky block progress bar for the Now Playing screen, sitting in the gap /// between the album art and the title with the elapsed / total times flanking /// it. Isolated as its own ConsumerWidget (like the mini-player and home hero) /// so position ticks rebuild only this strip, not the art or title text. class _NowPlayingProgress extends ConsumerWidget { const _NowPlayingProgress(); @override Widget build(BuildContext context, WidgetRef ref) { final position = ref.watch(activePlaybackProvider.select((s) => s.position)); final duration = ref.watch(activePlaybackProvider.select((s) => s.effectiveDuration)); final progress = ref.watch(activePlaybackProvider.select((s) => s.progress)); return Row( children: [ Text(_fmtDur(position), style: const TextStyle(color: TimbreColors.dimmed)), const SizedBox(width: TimbreSpacing.md), Expanded( child: BlockProgressBar(progress: progress, cells: 28, height: 18), ), const SizedBox(width: TimbreSpacing.md), Text(_fmtDur(duration), style: const TextStyle(color: TimbreColors.dimmed)), ], ); } } class _Transport extends StatelessWidget { const _Transport( {required this.state, required this.ref, required this.accent}); final PlaybackState state; final WidgetRef ref; final Color accent; @override Widget build(BuildContext context) { final controller = ref.read(playbackCommandsProvider); final loopIcon = switch (state.loop) { LoopMode.one => Icons.repeat_one, _ => Icons.repeat, }; return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _btn(Icons.shuffle, controller.toggleShuffle, color: state.shuffle ? accent : TimbreColors.dimmed), _btn(Icons.skip_previous, controller.previous), _btn(state.playing ? Icons.pause : Icons.play_arrow, controller.togglePlayPause, color: accent, size: 40), _btn(Icons.skip_next, controller.next), _btn(loopIcon, controller.cycleLoop, color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed), ], ); } Widget _btn(IconData icon, VoidCallback onTap, {Color color = TimbreColors.foreground, double size = 28}) { return IconButton( onPressed: onTap, icon: Icon(icon, color: color, size: size), ); } } class _ArtFallback extends StatelessWidget { const _ArtFallback(); @override Widget build(BuildContext context) => const Center( child: Icon(Icons.album_outlined, color: TimbreColors.dimmed, size: 48), ); } String _fmt(int? seconds) { if (seconds == null) return ''; final m = seconds ~/ 60; final s = seconds % 60; return '$m:${s.toString().padLeft(2, '0')}'; } String _fmtDur(Duration d) { final m = d.inMinutes; final s = d.inSeconds % 60; return '$m:${s.toString().padLeft(2, '0')}'; }