init
This commit is contained in:
commit
d205277cdd
182 changed files with 22978 additions and 0 deletions
388
lib/screens/now_playing_screen.dart
Normal file
388
lib/screens/now_playing_screen.dart
Normal 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')}';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue