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 '../playback/playback_engine.dart'; import '../state/providers.dart'; import '../subsonic/models.dart'; import '../theme/tokens.dart'; import '../widgets/block_progress_bar.dart'; import '../widgets/hairline_panel.dart'; import 'add_to_playlist_sheet.dart'; /// Now Playing tab — album art + info strip + transport, bound to the live /// playback engine. The top region shows the full-size album art by default and /// swaps to the queue when toggled, so the art keeps its original size while the /// queue still gets a usable amount of space on demand. class NowPlayingScreen extends ConsumerStatefulWidget { const NowPlayingScreen({super.key}); @override ConsumerState createState() => _NowPlayingScreenState(); } class _NowPlayingScreenState extends ConsumerState { bool _showQueue = false; @override Widget build(BuildContext context) { final state = ref.watch(playbackProvider); final accent = Theme.of(context).colorScheme.primary; final current = state.current; if (current == null) { return const Center( child: Text('Nothing playing.', style: TextStyle(color: RatuneColors.dimmed)), ); } return Padding( padding: const EdgeInsets.fromLTRB( RatuneSpacing.lg, RatuneSpacing.xl, RatuneSpacing.lg, RatuneSpacing.lg, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // The album art keeps its natural (width-bound square) size; when the // queue is toggled on it takes over this same region. Expanded( child: _showQueue ? const _QueuePanel() : const _AlbumArtPanel(), ), const SizedBox(height: RatuneSpacing.xl), _InfoStrip(song: current, state: state, accent: accent), const SizedBox(height: RatuneSpacing.sm), _FavRating(song: current), const SizedBox(height: RatuneSpacing.xs), _Transport(state: state, ref: ref, accent: accent), if (!state.supported) const Padding( padding: EdgeInsets.only(top: RatuneSpacing.sm), child: Text( 'Audio output unavailable on this platform — test on Android/iOS.', style: TextStyle(color: RatuneColors.dimmed, fontSize: 11), ), ), const SizedBox(height: RatuneSpacing.sm), _QueueToggle( showQueue: _showQueue, queueLength: state.queue.length, accent: accent, onTap: () => setState(() => _showQueue = !_showQueue), ), ], ), ); } } /// 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: RatuneSpacing.lg, vertical: RatuneSpacing.sm, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( showQueue ? Icons.album_outlined : Icons.queue_music, size: 16, color: showQueue ? accent : RatuneColors.dimmed, ), const SizedBox(width: RatuneSpacing.sm), Text( showQueue ? 'Album art' : 'Queue ($queueLength)', style: TextStyle( color: showQueue ? accent : RatuneColors.foreground, ), ), ], ), ), ), ); } } /// The play queue with reorder-free remove controls. Fills whichever space the /// top region gives it. class _QueuePanel extends ConsumerWidget { const _QueuePanel(); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(playbackProvider); final accent = Theme.of(context).colorScheme.primary; return HairlinePanel( title: 'Queue', trailing: '(${state.queue.length})', padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md), child: ListView.builder( padding: EdgeInsets.zero, itemCount: state.queue.length, itemBuilder: (context, i) { final song = state.queue[i]; final isCurrent = i == state.currentIndex; return InkWell( onTap: () => ref .read(playbackProvider.notifier) .playSongs(state.queue, startIndex: i), child: Padding( padding: const EdgeInsets.symmetric( horizontal: RatuneSpacing.lg, vertical: RatuneSpacing.xs, ), child: Row( children: [ SizedBox( width: 28, child: Text('${i + 1}', style: const TextStyle(color: RatuneColors.dimmed)), ), Expanded( child: Text( song.title ?? 'Untitled', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: isCurrent ? accent : RatuneColors.foreground, fontWeight: isCurrent ? FontWeight.w700 : FontWeight.w400, ), ), ), Text(_fmt(song.duration), style: const TextStyle(color: RatuneColors.dimmed)), InkWell( onTap: () => ref.read(playbackProvider.notifier).removeAt(i), customBorder: const CircleBorder(), child: const SizedBox( width: RatuneSpacing.minTouchTarget, height: RatuneSpacing.minTouchTarget, child: Icon(Icons.close, size: 18, color: RatuneColors.dimmed), ), ), ], ), ), ); }, ), ); } } /// 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(playbackProvider.select((s) => s.current?.coverArt)); final client = ref.watch(subsonicClientProvider); final artUri = (client != null && coverArt != null) ? client.coverArtUri(coverArt, size: 512).toString() : null; return HairlinePanel( title: 'Album Art', padding: const EdgeInsets.all(RatuneSpacing.md), child: AspectRatio( aspectRatio: 1, child: ColoredBox( color: RatuneColors.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(RatuneSpacing.sm), child: Icon( starred ? Icons.favorite : Icons.favorite_border, color: starred ? accent : RatuneColors.dimmed, size: 22, ), ), ), const SizedBox(width: RatuneSpacing.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 : RatuneColors.dimmed, ), ), ), const Spacer(), InkWell( onTap: () => showAddToPlaylistSheet(context, songs: [song]), customBorder: const CircleBorder(), child: const Padding( padding: EdgeInsets.all(RatuneSpacing.sm), child: Icon(Icons.playlist_add, size: 22, color: RatuneColors.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(RatuneSpacing.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 : RatuneColors.dimmed, ), ), ), ], ); } } class _InfoStrip extends StatelessWidget { const _InfoStrip( {required this.song, required this.state, required this.accent}); final Song song; final PlaybackState state; 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: RatuneColors.foreground)), if (album.isNotEmpty) Text(album, style: const TextStyle(color: RatuneColors.dimmed)), const SizedBox(height: RatuneSpacing.md), Row( children: [ Text(_fmtDur(state.position), style: const TextStyle(color: RatuneColors.dimmed)), const SizedBox(width: RatuneSpacing.md), Expanded(child: BlockProgressBar(progress: state.progress)), const SizedBox(width: RatuneSpacing.md), Text(_fmtDur(state.duration), style: const TextStyle(color: RatuneColors.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(playbackProvider.notifier); 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 : RatuneColors.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 : RatuneColors.dimmed), ], ); } Widget _btn(IconData icon, VoidCallback onTap, {Color color = RatuneColors.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: RatuneColors.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')}'; }