b6b73fdd0c
Why the seek bar appeared frozen: it was reading PlaybackState.updatePosition, which audio_service only updates on event transitions (play/pause/buffer/seek). Between events it sits unchanged, so the bar only jumped at intervals. Expose just_audio's positionStream (~200ms cadence) from the audio handler, wrap as positionProvider, and read that in both the mini bar and the full player. Now the bar advances continuously while playing. While we're here: spread the full player's vertical layout per operator — gap to seek 20→32, seek-to-primary 8→24, primary-to- secondary 16→24, plus 24 below to match. The full player had visible slack above the controls; this redistributes it.
399 lines
12 KiB
Dart
399 lines
12 KiB
Dart
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';
|
|
|
|
/// 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
|
|
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;
|
|
|
|
if (media == null) {
|
|
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
|
}
|
|
|
|
// Use positionProvider (just_audio's positionStream, ~200ms) for
|
|
// the seek bar so it scrubs live; PlaybackState.updatePosition
|
|
// only fires on state transitions and would leave the bar frozen
|
|
// between them.
|
|
final pos = ref.watch(positionProvider).value ?? 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,
|
|
// 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,
|
|
),
|
|
if ((media.album ?? '').isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
media.album!,
|
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
const SizedBox(height: 32),
|
|
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
|
const SizedBox(height: 24),
|
|
_PrimaryControls(
|
|
fs: fs,
|
|
ref: ref,
|
|
isPlaying: isPlaying,
|
|
),
|
|
const SizedBox(height: 24),
|
|
_SecondaryControls(
|
|
fs: fs,
|
|
actions: actions,
|
|
shuffleOn: shuffleOn,
|
|
repeatMode: repeatMode,
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|