This commit is contained in:
Forrest 2026-07-29 14:14:18 -04:00
commit d205277cdd
182 changed files with 22978 additions and 0 deletions

View file

@ -0,0 +1,388 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart' show LoopMode;
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';
/// 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);
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,
),
),
),
],
);
}
}
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')}';
}