mobile-music/lib/widgets/mini_player.dart
2026-07-29 16:22:00 -04:00

136 lines
4.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
/// Persistent mini-player pinned above the tab bar. Visible only while a track
/// is loaded; tapping the body jumps to the Now Playing tab.
///
/// Watches `current`/`playing` via `.select()` so it doesn't rebuild on every
/// position tick — the moving progress line is isolated in [_MiniProgress].
class MiniPlayer extends ConsumerWidget {
const MiniPlayer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Redundant (and steals vertical space) on the Now Playing tab itself.
if (ref.watch(selectedTabProvider) == nowPlayingTabIndex) {
return const SizedBox.shrink();
}
final current = ref.watch(playbackProvider.select((s) => s.current));
if (current == null) return const SizedBox.shrink();
final playing = ref.watch(playbackProvider.select((s) => s.playing));
final controller = ref.read(playbackProvider.notifier);
final client = ref.watch(subsonicClientProvider);
final accent = Theme.of(context).colorScheme.primary;
final artUri = (client != null && current.coverArt != null)
? client.coverArtUri(current.coverArt!, size: 128).toString()
: null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const _MiniProgress(),
InkWell(
onTap: () =>
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex,
child: Container(
height: 52,
color: TimbreColors.surface,
padding: const EdgeInsets.only(left: TimbreSpacing.md),
child: Row(
children: [
SizedBox(
width: 40,
height: 40,
child: ColoredBox(
color: TimbreColors.background,
child: artUri != null
? Image.network(
artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _ArtFallback(),
)
: const _ArtFallback(),
),
),
const SizedBox(width: TimbreSpacing.md),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
current.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
),
),
Text(
current.artist ?? 'Unknown artist',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.dimmed),
),
],
),
),
_btn(Icons.skip_previous, controller.previous),
_btn(playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause,
color: accent),
_btn(Icons.skip_next, controller.next),
],
),
),
),
],
);
}
Widget _btn(IconData icon, VoidCallback onTap,
{Color color = TimbreColors.foreground}) {
return IconButton(
onPressed: onTap,
visualDensity: VisualDensity.compact,
icon: Icon(icon, color: color, size: 24),
);
}
}
/// The hairline progress line on the top edge. Isolated so only this 2px strip
/// rebuilds on position ticks.
class _MiniProgress extends ConsumerWidget {
const _MiniProgress();
@override
Widget build(BuildContext context, WidgetRef ref) {
final progress = ref.watch(playbackProvider.select((s) => s.progress));
final accent = Theme.of(context).colorScheme.primary;
return SizedBox(
height: 2,
child: LinearProgressIndicator(
value: progress,
minHeight: 2,
backgroundColor: TimbreColors.border,
valueColor: AlwaysStoppedAnimation<Color>(accent),
),
);
}
}
class _ArtFallback extends StatelessWidget {
const _ArtFallback();
@override
Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined,
color: TimbreColors.dimmed, size: 22),
);
}