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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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 '../models/track.dart';
|
||||||
|
import '../shared/widgets/server_image.dart';
|
||||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'player_provider.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});
|
const NowPlayingScreen({super.key});
|
||||||
|
|
||||||
@override
|
@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 fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final media = ref.watch(mediaItemProvider).value;
|
final media = ref.watch(mediaItemProvider).value;
|
||||||
final playback = ref.watch(playbackStateProvider).value;
|
final playback = ref.watch(playbackStateProvider).value;
|
||||||
@@ -22,85 +64,322 @@ class NowPlayingScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
final pos = playback?.updatePosition ?? Duration.zero;
|
final pos = playback?.updatePosition ?? Duration.zero;
|
||||||
final dur = media.duration ?? 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(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
appBar: AppBar(
|
// The whole screen accepts vertical drag for dismissal. Buttons
|
||||||
backgroundColor: fs.obsidian,
|
// and the seek slider are still tappable since gesture arena
|
||||||
leading: IconButton(
|
// gives priority to their child gesture detectors.
|
||||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
body: GestureDetector(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onVerticalDragStart: _onDragStart,
|
||||||
),
|
onVerticalDragUpdate: _onDragUpdate,
|
||||||
actions: [
|
onVerticalDragEnd: _onDragEnd,
|
||||||
IconButton(
|
behavior: HitTestBehavior.translucent,
|
||||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
child: SafeArea(
|
||||||
tooltip: 'Queue',
|
child: Column(
|
||||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,20 +12,20 @@ import '../shared/widgets/track_actions/track_actions_button.dart';
|
|||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'player_provider.dart';
|
import 'player_provider.dart';
|
||||||
|
|
||||||
/// Compact player bar mounted in the app shell. Layout:
|
/// Compact player bar mounted at the bottom of the app shell. Mini
|
||||||
|
/// view only — the heavyweight shuffle/repeat/queue/volume controls
|
||||||
|
/// live in NowPlayingScreen, accessible by tap or swipe-up.
|
||||||
///
|
///
|
||||||
/// ┌──────────────────────────────┬──────────────┐
|
/// ┌─────────────────────────────────────┬──────────┐
|
||||||
/// │ [art] Title ♥ ⋮ │ 🔀 🔁 ☰ │
|
/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │
|
||||||
/// │ Artist │ ⏮ ⏯ ⏭ │
|
/// │ Artist │ │
|
||||||
/// ├──────────────────────────────┴──────────────┤
|
/// ├─────────────────────────────────────────────────┤
|
||||||
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
||||||
/// └───────────────────────────────────────────────┘
|
/// └─────────────────────────────────────────────────┘
|
||||||
///
|
///
|
||||||
/// Track info gets a 2:1 share of the row vs the controls column on
|
/// Tap anywhere on the bar (including art/title) ascends to the full
|
||||||
/// the right. Heart + kebab stay inline at the right of the title.
|
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
||||||
/// Right column stacks shuffle/repeat/queue above prev/play/next.
|
/// the drag-down dismissal on the full screen.
|
||||||
/// Volume is handled by the phone's hardware buttons. Bottom:
|
|
||||||
/// full-width seek + time labels.
|
|
||||||
class PlayerBar extends ConsumerWidget {
|
class PlayerBar extends ConsumerWidget {
|
||||||
const PlayerBar({super.key});
|
const PlayerBar({super.key});
|
||||||
|
|
||||||
@@ -41,38 +41,43 @@ class PlayerBar extends ConsumerWidget {
|
|||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: fs.iron,
|
color: fs.iron,
|
||||||
child: Padding(
|
child: GestureDetector(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Column(
|
onTap: () => context.push('/now-playing'),
|
||||||
mainAxisSize: MainAxisSize.min,
|
// Swipe up anywhere on the bar to expand into the full player.
|
||||||
children: [
|
// We only act on a clear upward flick (>200 px/s) so a slow
|
||||||
// Top row: track info (2 fill) | controls column (1 fill).
|
// tap-with-tiny-jitter doesn't accidentally open the screen.
|
||||||
// The controls column stacks shuffle/repeat/queue above the
|
onVerticalDragEnd: (d) {
|
||||||
// prev/play/next row.
|
final v = d.primaryVelocity ?? 0;
|
||||||
IntrinsicHeight(
|
if (v < -200) context.push('/now-playing');
|
||||||
child: Row(
|
},
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
Expanded(flex: 2, child: _TrackInfoColumn(media: media)),
|
child: Column(
|
||||||
const SizedBox(width: 8),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Expanded(
|
children: [
|
||||||
flex: 1,
|
IntrinsicHeight(
|
||||||
child: _ControlsColumn(playback: playback),
|
child: Row(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
],
|
children: [
|
||||||
|
Expanded(child: _TrackInfo(media: media)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_PlayControls(playback: playback, ref: ref),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 4),
|
_SeekRow(position: pos, duration: dur),
|
||||||
_SeekRow(position: pos, duration: dur),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TrackInfoColumn extends StatelessWidget {
|
class _TrackInfo extends StatelessWidget {
|
||||||
const _TrackInfoColumn({required this.media});
|
const _TrackInfo({required this.media});
|
||||||
final MediaItem media;
|
final MediaItem media;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -80,209 +85,130 @@ class _TrackInfoColumn extends StatelessWidget {
|
|||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final artistName = (media.artist ?? '').trim();
|
final artistName = (media.artist ?? '').trim();
|
||||||
|
|
||||||
return InkWell(
|
return Row(
|
||||||
onTap: () => context.push('/now-playing'),
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (media.artUri != null)
|
|
||||||
Image(
|
|
||||||
// artUri is a file:// path written by AlbumCoverCache —
|
|
||||||
// FileImage is the right loader; NetworkImage would
|
|
||||||
// attempt HTTP on a file:// URI and fail.
|
|
||||||
image: media.artUri!.isScheme('file')
|
|
||||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
|
||||||
: NetworkImage(media.artUri.toString()),
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
errorBuilder: (_, __, ___) =>
|
|
||||||
Container(width: 48, height: 48, color: fs.slate),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Container(width: 48, height: 48, color: fs.slate),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Title row: title text + inline like + kebab. The
|
|
||||||
// like/kebab buttons get their default 48dp tap target
|
|
||||||
// shrunk to 32dp so the title has room — otherwise it
|
|
||||||
// collapses to nothing in the available width.
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.max,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
media.title,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
child: LikeButton(
|
|
||||||
kind: LikeKind.track,
|
|
||||||
id: media.id,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
child: TrackActionsButton(
|
|
||||||
track: _trackRefFromMediaItem(media),
|
|
||||||
hideQueueActions: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (artistName.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
artistName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ControlsColumn extends ConsumerWidget {
|
|
||||||
const _ControlsColumn({required this.playback});
|
|
||||||
final PlaybackState? playback;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
||||||
final isPlaying = playback?.playing == true;
|
|
||||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
|
||||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
|
||||||
final actions = ref.read(playerActionsProvider);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
children: [
|
||||||
// Top sub-row: shuffle / repeat / queue
|
if (media.artUri != null)
|
||||||
Row(
|
Image(
|
||||||
mainAxisSize: MainAxisSize.min,
|
image: media.artUri!.isScheme('file')
|
||||||
children: [
|
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
||||||
_IconBtn(
|
: NetworkImage(media.artUri.toString()),
|
||||||
size: 32,
|
width: 48,
|
||||||
iconSize: 18,
|
height: 48,
|
||||||
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
errorBuilder: (_, __, ___) =>
|
||||||
icon: Icons.shuffle,
|
Container(width: 48, height: 48, color: fs.slate),
|
||||||
color: shuffleOn ? fs.accent : fs.ash,
|
)
|
||||||
onPressed: actions.toggleShuffle,
|
else
|
||||||
),
|
Container(width: 48, height: 48, color: fs.slate),
|
||||||
_IconBtn(
|
const SizedBox(width: 12),
|
||||||
size: 32,
|
Expanded(
|
||||||
iconSize: 18,
|
child: Column(
|
||||||
tooltip: switch (repeatMode) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
AudioServiceRepeatMode.none => 'Repeat off',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
AudioServiceRepeatMode.one => 'Repeat one',
|
mainAxisSize: MainAxisSize.min,
|
||||||
_ => 'Repeat all',
|
children: [
|
||||||
},
|
// Title row: title text + inline like + kebab. The
|
||||||
icon: repeatMode == AudioServiceRepeatMode.one
|
// like/kebab buttons get their default 48dp tap target
|
||||||
? Icons.repeat_one
|
// shrunk to 32dp so the title has room.
|
||||||
: Icons.repeat,
|
Row(
|
||||||
color: repeatMode == AudioServiceRepeatMode.none
|
mainAxisSize: MainAxisSize.max,
|
||||||
? fs.ash
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
: fs.accent,
|
children: [
|
||||||
onPressed: actions.cycleRepeat,
|
Expanded(
|
||||||
),
|
child: Text(
|
||||||
_IconBtn(
|
media.title,
|
||||||
size: 32,
|
maxLines: 1,
|
||||||
iconSize: 18,
|
overflow: TextOverflow.ellipsis,
|
||||||
tooltip: 'Queue',
|
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||||
icon: Icons.queue_music,
|
),
|
||||||
color: fs.ash,
|
),
|
||||||
onPressed: () => context.push('/queue'),
|
SizedBox(
|
||||||
),
|
width: 32,
|
||||||
],
|
height: 32,
|
||||||
),
|
child: LikeButton(
|
||||||
// Bottom sub-row: prev / play / next. Sized to match the
|
kind: LikeKind.track,
|
||||||
// shuffle/repeat/queue row above (3×32 = 96dp) with the play
|
id: media.id,
|
||||||
// button slightly larger; total 32+40+32 = 104dp.
|
size: 18,
|
||||||
Row(
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
SizedBox(
|
||||||
_IconBtn(
|
width: 32,
|
||||||
size: 32,
|
height: 32,
|
||||||
iconSize: 20,
|
child: TrackActionsButton(
|
||||||
icon: Icons.skip_previous,
|
track: _trackRefFromMediaItem(media),
|
||||||
color: fs.parchment,
|
hideQueueActions: true,
|
||||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
),
|
||||||
),
|
),
|
||||||
_IconBtn(
|
],
|
||||||
size: 40,
|
),
|
||||||
iconSize: 26,
|
if (artistName.isNotEmpty)
|
||||||
icon: isPlaying
|
Text(
|
||||||
? Icons.pause_circle_filled
|
artistName,
|
||||||
: Icons.play_circle_filled,
|
maxLines: 1,
|
||||||
color: fs.accent,
|
overflow: TextOverflow.ellipsis,
|
||||||
onPressed: () {
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
final h = ref.read(audioHandlerProvider);
|
),
|
||||||
if (isPlaying) {
|
],
|
||||||
h.pause();
|
),
|
||||||
} else {
|
|
||||||
h.play();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_IconBtn(
|
|
||||||
size: 32,
|
|
||||||
iconSize: 20,
|
|
||||||
icon: Icons.skip_next,
|
|
||||||
color: fs.parchment,
|
|
||||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IconBtn extends StatelessWidget {
|
class _PlayControls extends StatelessWidget {
|
||||||
const _IconBtn({
|
const _PlayControls({required this.playback, required this.ref});
|
||||||
required this.size,
|
final PlaybackState? playback;
|
||||||
required this.iconSize,
|
final WidgetRef ref;
|
||||||
required this.icon,
|
|
||||||
required this.color,
|
|
||||||
required this.onPressed,
|
|
||||||
this.tooltip,
|
|
||||||
});
|
|
||||||
final double size;
|
|
||||||
final double iconSize;
|
|
||||||
final IconData icon;
|
|
||||||
final Color color;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
final String? tooltip;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
width: size,
|
final isPlaying = playback?.playing == true;
|
||||||
height: size,
|
return Row(
|
||||||
child: IconButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
padding: EdgeInsets.zero,
|
children: [
|
||||||
tooltip: tooltip,
|
SizedBox(
|
||||||
iconSize: iconSize,
|
width: 36,
|
||||||
icon: Icon(icon, color: color),
|
height: 36,
|
||||||
onPressed: onPressed,
|
child: IconButton(
|
||||||
),
|
padding: EdgeInsets.zero,
|
||||||
|
iconSize: 22,
|
||||||
|
icon: Icon(Icons.skip_previous, color: fs.parchment),
|
||||||
|
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
iconSize: 32,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
iconSize: 22,
|
||||||
|
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||||
|
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,32 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
path: '/albums/:id',
|
path: '/albums/:id',
|
||||||
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
|
GoRoute(
|
||||||
|
path: '/now-playing',
|
||||||
|
// Slide-up transition: full player ascends from the bottom
|
||||||
|
// edge (matching where the mini player sits) into a full
|
||||||
|
// screen view. Drag-down dismissal in NowPlayingScreen
|
||||||
|
// mirrors this with a slide back down via Navigator.pop.
|
||||||
|
pageBuilder: (_, __) => CustomTransitionPage(
|
||||||
|
child: const NowPlayingScreen(),
|
||||||
|
transitionDuration: const Duration(milliseconds: 280),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 240),
|
||||||
|
transitionsBuilder: (_, anim, __, child) {
|
||||||
|
final eased = CurvedAnimation(
|
||||||
|
parent: anim,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
return SlideTransition(
|
||||||
|
position: Tween(
|
||||||
|
begin: const Offset(0, 1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(eased),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||||
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
||||||
@@ -101,6 +126,11 @@ class _ShellWithPlayerBar extends ConsumerWidget {
|
|||||||
// When the banner is showing, strip that inset from the child so
|
// When the banner is showing, strip that inset from the child so
|
||||||
// its AppBar lands directly under the banner.
|
// its AppBar lands directly under the banner.
|
||||||
final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null;
|
final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null;
|
||||||
|
// Hide the mini player when the full player is on top — the full
|
||||||
|
// player IS the player UI in that state, and overlapping them
|
||||||
|
// would just fight for visual space.
|
||||||
|
final loc = GoRouterState.of(context).matchedLocation;
|
||||||
|
final hideMini = loc == '/now-playing';
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const UpdateBanner(),
|
const UpdateBanner(),
|
||||||
@@ -113,7 +143,7 @@ class _ShellWithPlayerBar extends ConsumerWidget {
|
|||||||
)
|
)
|
||||||
: child,
|
: child,
|
||||||
),
|
),
|
||||||
const PlayerBar(),
|
if (!hideMini) const PlayerBar(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user