mobile-music/lib/screens/now_playing_screen.dart
2026-07-29 16:22:00 -04:00

431 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
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: TimbreColors.dimmed)),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(
TimbreSpacing.lg,
TimbreSpacing.xl,
TimbreSpacing.lg,
TimbreSpacing.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: TimbreSpacing.xl),
_InfoStrip(song: current, state: state, 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),
),
),
const SizedBox(height: TimbreSpacing.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: 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.
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: TimbreSpacing.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: TimbreSpacing.lg,
vertical: TimbreSpacing.xs,
),
child: Row(
children: [
SizedBox(
width: 28,
child: Text('${i + 1}',
style: const TextStyle(color: TimbreColors.dimmed)),
),
Expanded(
child: Text(
song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isCurrent ? accent : TimbreColors.foreground,
fontWeight:
isCurrent ? FontWeight.w700 : FontWeight.w400,
),
),
),
Text(_fmt(song.duration),
style: const TextStyle(color: TimbreColors.dimmed)),
InkWell(
onTap: () =>
ref.read(playbackProvider.notifier).removeAt(i),
customBorder: const CircleBorder(),
child: const SizedBox(
width: TimbreSpacing.minTouchTarget,
height: TimbreSpacing.minTouchTarget,
child: Icon(Icons.close,
size: 18, color: TimbreColors.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(TimbreSpacing.md),
child: 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,
),
),
),
],
);
}
}
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: TimbreColors.foreground)),
if (album.isNotEmpty)
Text(album, style: const TextStyle(color: TimbreColors.dimmed)),
const SizedBox(height: TimbreSpacing.md),
Row(
children: [
Text(_fmtDur(state.position),
style: const TextStyle(color: TimbreColors.dimmed)),
const SizedBox(width: TimbreSpacing.md),
Expanded(child: BlockProgressBar(progress: state.progress)),
const SizedBox(width: TimbreSpacing.md),
Text(_fmtDur(state.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(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 : 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')}';
}