431 lines
14 KiB
Dart
431 lines
14 KiB
Dart
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: 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')}';
|
||
}
|