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.
292 lines
10 KiB
Dart
292 lines
10 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/track_actions/track_actions_button.dart';
|
|
import '../theme/theme_extension.dart';
|
|
import 'player_provider.dart';
|
|
|
|
/// 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 ♥ ⋮ │ ⏮ ⏯ ⏭ │
|
|
/// │ Artist │ │
|
|
/// ├─────────────────────────────────────────────────┤
|
|
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
|
/// └─────────────────────────────────────────────────┘
|
|
///
|
|
/// Tap anywhere on the bar (including art/title) ascends to the full
|
|
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
|
/// the drag-down dismissal on the full screen.
|
|
class PlayerBar extends ConsumerWidget {
|
|
const PlayerBar({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final media = ref.watch(mediaItemProvider).value;
|
|
final playback = ref.watch(playbackStateProvider).value;
|
|
if (media == null) return const SizedBox.shrink();
|
|
|
|
// positionProvider is just_audio's positionStream (~200ms cadence)
|
|
// so the mini bar's seek crawls forward smoothly. PlaybackState
|
|
// only updates on event transitions and would leave it frozen.
|
|
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
|
final dur = media.duration ?? Duration.zero;
|
|
|
|
return Material(
|
|
color: fs.iron,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => context.push('/now-playing'),
|
|
// Swipe up anywhere on the bar to expand into the full player.
|
|
// We only act on a clear upward flick (>200 px/s) so a slow
|
|
// tap-with-tiny-jitter doesn't accidentally open the screen.
|
|
onVerticalDragEnd: (d) {
|
|
final v = d.primaryVelocity ?? 0;
|
|
if (v < -200) context.push('/now-playing');
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(child: _TrackInfo(media: media)),
|
|
const SizedBox(width: 8),
|
|
_PlayControls(playback: playback, ref: ref),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
_SeekRow(position: pos, duration: dur),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TrackInfo extends StatelessWidget {
|
|
const _TrackInfo({required this.media});
|
|
final MediaItem media;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final artistName = (media.artist ?? '').trim();
|
|
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (media.artUri != null)
|
|
Image(
|
|
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.
|
|
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 _PlayControls extends StatelessWidget {
|
|
const _PlayControls({required this.playback, required this.ref});
|
|
final PlaybackState? playback;
|
|
final WidgetRef ref;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final isPlaying = playback?.playing == true;
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
width: 36,
|
|
height: 36,
|
|
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(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeekRow extends ConsumerWidget {
|
|
const _SeekRow({required this.position, required this.duration});
|
|
final Duration position;
|
|
final Duration duration;
|
|
|
|
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, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final maxMs =
|
|
duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
|
|
return Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 36,
|
|
child: Text(
|
|
_fmt(position),
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(color: fs.ash, fontSize: 10),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: SliderTheme(
|
|
data: SliderTheme.of(context).copyWith(
|
|
trackHeight: 2,
|
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
|
|
),
|
|
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())),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 36,
|
|
child: Text(
|
|
_fmt(duration),
|
|
style: TextStyle(color: fs.ash, fontSize: 10),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Reconstructs a minimal TrackRef from a MediaItem so the
|
|
/// TrackActionsButton has the fields its sheet expects. Mirrors the
|
|
/// pattern used by NowPlayingScreen — extras['album_id'] is what
|
|
/// audio_handler stashed at queue-build time.
|
|
TrackRef _trackRefFromMediaItem(MediaItem media) => 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: '',
|
|
);
|