feat(flutter): mini ↔ full player transition + flesh out NowPlayingScreen
Mini player simplifies to track-info + prev/play/next. The shuffle/repeat/queue cluster moves to the full player, where it has the room to breathe. Full player (NowPlayingScreen) now has: - Real album art (FileImage when artUri is file://, ServerImage from /api/albums/<id>/cover otherwise — same auth-aware loader as the rest of the app, with errorBuilder so a failed fetch falls back to a slate placeholder instead of a broken-image glyph). - Title row with inline like + kebab. - Full-width seek with start/end timestamps below. - 36/72/36 prev/play/next. - Secondary row: shuffle, repeat, queue. Mini ↔ Full transition mechanics: - Tap mini → push /now-playing with custom slide-up transition (CustomTransitionPage, 280ms easeOutCubic). - Vertical drag-up flick on mini (>200 px/s) → same push. - System back / leading expand-more icon → Navigator.pop, which the CustomTransitionPage replays as a slide-down (240ms easeInCubic). - Drag-down on full player past 80px OR a >500 px/s downward flick → Navigator.pop. Buttons and the seek slider stay tappable since Flutter's gesture arena gives priority to the more-specific child recognizers. - Mini player hides while /now-playing is the active route — the full player IS the player UI in that state, no need to fight over visual space.
This commit is contained in:
@@ -1,17 +1,59 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart' show LikeKind;
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/track.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'player_provider.dart';
|
||||
|
||||
class NowPlayingScreen extends ConsumerWidget {
|
||||
/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up
|
||||
/// transition (see routing.dart). Dismisses three ways:
|
||||
///
|
||||
/// - System back / leading button → Navigator.pop
|
||||
/// - Vertical drag down past threshold → Navigator.pop (matches the
|
||||
/// slide-down animation users expect from a "pull down to close"
|
||||
/// sheet)
|
||||
/// - Tap of the mini player ascends here; reverse motion descends back.
|
||||
class NowPlayingScreen extends ConsumerStatefulWidget {
|
||||
const NowPlayingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
|
||||
}
|
||||
|
||||
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
// Cumulative drag distance used to decide if a vertical drag should
|
||||
// dismiss. Reset on each drag start.
|
||||
double _dragOffset = 0;
|
||||
|
||||
void _onDragStart(DragStartDetails _) {
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails d) {
|
||||
_dragOffset += d.delta.dy;
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails d) {
|
||||
// Pop when either the user has dragged > 80px down OR flicked
|
||||
// downward at speed (>500 px/s). Either should feel responsive
|
||||
// without dismissing on accidental drags.
|
||||
final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500;
|
||||
if (_dragOffset > 80 || flicked) {
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final media = ref.watch(mediaItemProvider).value;
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
@@ -22,85 +64,322 @@ class NowPlayingScreen extends ConsumerWidget {
|
||||
|
||||
final pos = playback?.updatePosition ?? Duration.zero;
|
||||
final dur = media.duration ?? Duration.zero;
|
||||
final isPlaying = playback?.playing == true;
|
||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
||||
final actions = ref.read(playerActionsProvider);
|
||||
final albumId = (media.extras?['album_id'] as String?) ?? '';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
||||
tooltip: 'Queue',
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
// The whole screen accepts vertical drag for dismissal. Buttons
|
||||
// and the seek slider are still tappable since gesture arena
|
||||
// gives priority to their child gesture detectors.
|
||||
body: GestureDetector(
|
||||
onVerticalDragStart: _onDragStart,
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: _onDragEnd,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_TopBar(fs: fs),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
_AlbumArt(media: media, albumId: albumId, fs: fs),
|
||||
const SizedBox(height: 28),
|
||||
_TitleRow(media: media, fs: fs),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
media.artist ?? '',
|
||||
style: TextStyle(color: fs.ash, fontSize: 14),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||
const SizedBox(height: 8),
|
||||
_PrimaryControls(
|
||||
fs: fs,
|
||||
ref: ref,
|
||||
isPlaying: isPlaying,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SecondaryControls(
|
||||
fs: fs,
|
||||
actions: actions,
|
||||
shuffleOn: shuffleOn,
|
||||
repeatMode: repeatMode,
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(children: [
|
||||
const Spacer(),
|
||||
Container(
|
||||
width: 280, height: 280, color: fs.slate,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
media.title,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 22),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
TrackActionsButton(
|
||||
track: TrackRef(
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
artistId: '', // not stashed in MediaItem.extras today
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
]),
|
||||
Text(media.artist ?? '', style: TextStyle(color: fs.ash, fontSize: 14)),
|
||||
const SizedBox(height: 16),
|
||||
Slider(
|
||||
activeColor: fs.accent,
|
||||
min: 0,
|
||||
max: dur.inMilliseconds.toDouble().clamp(1, double.infinity),
|
||||
value: pos.inMilliseconds.toDouble().clamp(0, dur.inMilliseconds.toDouble()),
|
||||
onChanged: (v) => ref.read(audioHandlerProvider).seek(Duration(milliseconds: v.toInt())),
|
||||
),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment, size: 32),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 56,
|
||||
icon: Icon(playback?.playing == true ? Icons.pause_circle_filled : Icons.play_circle_filled, color: fs.accent),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (playback?.playing == true) { h.pause(); } else { h.play(); }
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment, size: 32),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
]),
|
||||
const Spacer(),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.fs});
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
||||
tooltip: 'Queue',
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumArt extends StatelessWidget {
|
||||
const _AlbumArt({required this.media, required this.albumId, required this.fs});
|
||||
final MediaItem media;
|
||||
final String albumId;
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Pick the best available source. AlbumCoverCache writes a file://
|
||||
// URI to media.artUri once the cover lands on disk; before then,
|
||||
// fall back to fetching via ServerImage from /api/albums/<id>/cover
|
||||
// (which carries the auth header and falls back gracefully on
|
||||
// failure).
|
||||
Widget cover;
|
||||
final artUri = media.artUri;
|
||||
if (artUri != null && artUri.isScheme('file')) {
|
||||
cover = Image(
|
||||
image: FileImage(File.fromUri(artUri)),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: fs.slate),
|
||||
);
|
||||
} else if (albumId.isNotEmpty) {
|
||||
cover = ServerImage(
|
||||
url: '/api/albums/$albumId/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
);
|
||||
} else {
|
||||
cover = Container(color: fs.slate);
|
||||
}
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TitleRow extends StatelessWidget {
|
||||
const _TitleRow({required this.media, required this.fs});
|
||||
final MediaItem media;
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
media.title,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 22),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
|
||||
TrackActionsButton(
|
||||
track: TrackRef(
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
artistId: '',
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekRow extends StatelessWidget {
|
||||
const _SeekRow({
|
||||
required this.position,
|
||||
required this.duration,
|
||||
required this.fs,
|
||||
required this.ref,
|
||||
});
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final FabledSwordTheme fs;
|
||||
final WidgetRef ref;
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString();
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxMs = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
|
||||
return Column(children: [
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
|
||||
),
|
||||
child: Slider(
|
||||
activeColor: fs.accent,
|
||||
inactiveColor: fs.slate,
|
||||
min: 0,
|
||||
max: maxMs,
|
||||
value: position.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0.0, duration.inMilliseconds.toDouble()),
|
||||
onChanged: (v) => ref
|
||||
.read(audioHandlerProvider)
|
||||
.seek(Duration(milliseconds: v.toInt())),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(_fmt(position), style: TextStyle(color: fs.ash, fontSize: 11)),
|
||||
Text(_fmt(duration), style: TextStyle(color: fs.ash, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrimaryControls extends StatelessWidget {
|
||||
const _PrimaryControls({
|
||||
required this.fs,
|
||||
required this.ref,
|
||||
required this.isPlaying,
|
||||
});
|
||||
final FabledSwordTheme fs;
|
||||
final WidgetRef ref;
|
||||
final bool isPlaying;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 36,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 72,
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: fs.accent,
|
||||
),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (isPlaying) {
|
||||
h.pause();
|
||||
} else {
|
||||
h.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 36,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SecondaryControls extends StatelessWidget {
|
||||
const _SecondaryControls({
|
||||
required this.fs,
|
||||
required this.actions,
|
||||
required this.shuffleOn,
|
||||
required this.repeatMode,
|
||||
});
|
||||
final FabledSwordTheme fs;
|
||||
final PlayerActions actions;
|
||||
final bool shuffleOn;
|
||||
final AudioServiceRepeatMode repeatMode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
||||
icon: Icon(
|
||||
Icons.shuffle,
|
||||
color: shuffleOn ? fs.accent : fs.ash,
|
||||
),
|
||||
onPressed: actions.toggleShuffle,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: switch (repeatMode) {
|
||||
AudioServiceRepeatMode.none => 'Repeat off',
|
||||
AudioServiceRepeatMode.one => 'Repeat one',
|
||||
_ => 'Repeat all',
|
||||
},
|
||||
icon: Icon(
|
||||
repeatMode == AudioServiceRepeatMode.one
|
||||
? Icons.repeat_one
|
||||
: Icons.repeat,
|
||||
color: repeatMode == AudioServiceRepeatMode.none
|
||||
? fs.ash
|
||||
: fs.accent,
|
||||
),
|
||||
onPressed: actions.cycleRepeat,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Queue',
|
||||
icon: Icon(Icons.queue_music, color: fs.ash),
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user