Files
SpotiFLAC-Mobile/lib/screens/now_playing_screen.dart
T
zarzet 23488a9c05 feat(player): fade the sheet in place on close, letting only the cover travel
Button/back pops no longer slide the sheet down: it dissolves where it
stands while the Hero artwork flies back to the mini player. Drag
dismissals keep sliding from the finger's position since freezing a
half-dragged sheet mid-screen would look broken.
2026-07-14 09:09:51 +07:00

1262 lines
44 KiB
Dart

import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
final _log = AppLogger('NowPlaying');
/// Hero tag shared by the mini player artwork and the full player artwork so
/// the cover visually expands when the player opens.
const kNowPlayingArtworkHeroTag = 'now-playing-artwork';
/// Slide-up route for the full player. Supports live drag-to-dismiss: the
/// page follows the finger (via [startDrag]/[updateDrag]/[endDrag]) and
/// settles open or pops based on release position and velocity.
class NowPlayingRoute extends PageRoute<void> {
NowPlayingRoute() : super(fullscreenDialog: true);
bool _dragging = false;
bool _popViaDrag = false;
@override
Color? get barrierColor => null;
@override
String? get barrierLabel => null;
@override
bool get maintainState => true;
@override
Duration get transitionDuration => const Duration(milliseconds: 400);
@override
Duration get reverseTransitionDuration => const Duration(milliseconds: 300);
void startDrag() {
_dragging = true;
controller?.stop();
changedInternalState();
}
void updateDrag(DragUpdateDetails details, double pageHeight) {
controller?.value -= (details.primaryDelta ?? 0) / pageHeight;
}
void endDrag(DragEndDetails details, double pageHeight) {
_dragging = false;
changedInternalState();
final velocity = (details.primaryVelocity ?? 0) / pageHeight;
final value = controller?.value ?? 1.0;
if (velocity > 1.0 || (velocity >= 0 && value < 0.7)) {
_popViaDrag = true;
navigator?.pop();
} else {
controller?.fling();
}
}
void cancelDrag() {
_dragging = false;
changedInternalState();
controller?.fling();
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return _RouteDragRegion(route: this, child: const NowPlayingScreen());
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final reversing = animation.status == AnimationStatus.reverse;
// Button/back pop: the sheet stays put and fades out where it is, so the
// Hero cover is the only thing traveling down to the mini player. A drag
// dismissal instead keeps sliding from the finger's position, dissolving
// on the way down.
final inPlacePop = reversing && !_popViaDrag;
// Linear while dragging so the page tracks the finger 1:1.
final slide = _dragging
? animation
: CurvedAnimation(
parent: animation,
curve: Easing.emphasizedDecelerate,
reverseCurve: Easing.emphasizedAccelerate,
);
final position = inPlacePop
? const AlwaysStoppedAnimation(Offset.zero)
: slide.drive(Tween(begin: const Offset(0, 1), end: Offset.zero));
final opacity = inPlacePop
? CurvedAnimation(parent: animation, curve: const Interval(0.3, 1.0))
: reversing
? CurvedAnimation(parent: animation, curve: const Interval(0.0, 0.5))
: const AlwaysStoppedAnimation(1.0);
return SlideTransition(
position: position,
child: FadeTransition(opacity: opacity, child: child),
);
}
}
/// Routes vertical drags to [NowPlayingRoute]. Scrollables inside [child]
/// still win the gesture arena, so this only fires on non-scrolling regions
/// (app bar, artwork, tab bar).
class _RouteDragRegion extends StatelessWidget {
final NowPlayingRoute route;
final Widget child;
const _RouteDragRegion({required this.route, required this.child});
@override
Widget build(BuildContext context) {
final pageHeight = MediaQuery.sizeOf(context).height;
return GestureDetector(
onVerticalDragStart: (_) => route.startDrag(),
onVerticalDragUpdate: (details) => route.updateDrag(details, pageHeight),
onVerticalDragEnd: (details) => route.endDrag(details, pageHeight),
onVerticalDragCancel: route.cancelDrag,
child: child,
);
}
}
/// AppBar wrapper that fades with the route transition (see contentOpacity
/// in [_NowPlayingScreenState.build]).
class _FadingAppBar extends StatelessWidget implements PreferredSizeWidget {
final Animation<double> opacity;
final AppBar child;
const _FadingAppBar({required this.opacity, required this.child});
@override
Size get preferredSize => child.preferredSize;
@override
Widget build(BuildContext context) =>
FadeTransition(opacity: opacity, child: child);
}
class NowPlayingScreen extends ConsumerStatefulWidget {
const NowPlayingScreen({super.key});
@override
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
final PageController _pageController = PageController();
ProviderSubscription<AsyncValue<MediaItem?>>? _mediaItemSub;
String? _loadedSource;
Map<String, dynamic>? _metadata;
ParsedLyrics _lyrics = ParsedLyrics.empty;
bool _loadingMeta = false;
@override
void initState() {
super.initState();
_mediaItemSub = ref.listenManual<AsyncValue<MediaItem?>>(
currentMediaItemProvider,
(previous, next) => _loadMetadataForItem(next.value),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_loadMetadataForItem(ref.read(currentMediaItemProvider).value);
});
}
@override
void dispose() {
_mediaItemSub?.close();
_pageController.dispose();
super.dispose();
}
void _loadMetadataForItem(MediaItem? item) {
if (item == null) return;
final source = item.extras?['source']?.toString() ?? '';
if (source.isEmpty) return;
final resolvedSource = item.extras?['resolvedSource']?.toString();
unawaited(_loadMetadataFor(source, resolvedSource: resolvedSource));
}
Future<void> _loadMetadataFor(String source, {String? resolvedSource}) async {
if (source == _loadedSource) return;
_loadedSource = source;
setState(() {
_loadingMeta = true;
_metadata = null;
_lyrics = ParsedLyrics.empty;
});
try {
String path = (resolvedSource != null && resolvedSource.isNotEmpty)
? resolvedSource
: source;
if (path == source && source.startsWith('content://')) {
final temp = await PlatformBridge.copyContentUriToTemp(source);
if (temp == null || temp.isEmpty) {
throw Exception('Cannot resolve content URI');
}
path = temp;
}
final meta = await PlatformBridge.readFileMetadata(path);
if (!mounted || _loadedSource != source) return;
setState(() {
_metadata = meta;
_lyrics = LyricsParser.parse((meta['lyrics'] ?? '').toString());
_loadingMeta = false;
});
} catch (e) {
_log.w('Failed to read metadata: $e');
if (!mounted || _loadedSource != source) return;
setState(() {
_metadata = null;
_lyrics = ParsedLyrics.empty;
_loadingMeta = false;
});
}
}
String? _qualityLabel() {
final meta = _metadata;
if (meta == null) return null;
final parts = <String>[];
final format = (meta['format'] ?? meta['audio_codec'] ?? '')
.toString()
.trim()
.toUpperCase();
if (format.isNotEmpty) parts.add(format);
final bitDepth = (meta['bit_depth'] as num?)?.toInt() ?? 0;
if (bitDepth > 0) parts.add('$bitDepth-bit');
final sampleRate = (meta['sample_rate'] as num?)?.toDouble() ?? 0;
if (sampleRate > 0) {
final khz = sampleRate / 1000;
final khzStr = khz == khz.roundToDouble()
? khz.toStringAsFixed(0)
: khz.toStringAsFixed(1);
parts.add('$khzStr kHz');
}
final bitrate = (meta['bitrate'] as num?)?.toInt() ?? 0;
if (bitDepth == 0 && bitrate > 0) parts.add('$bitrate kbps');
if (parts.isEmpty) return null;
return parts.join(' · ');
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final mediaItem = ref.watch(currentMediaItemProvider).value;
final controller = ref.read(musicPlayerControllerProvider);
if (mediaItem == null) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.keyboard_arrow_down),
onPressed: () => Navigator.of(context).maybePop(),
),
),
body: Center(child: Text(context.l10n.nowPlayingNothingPlaying)),
);
}
final source = mediaItem.extras?['source']?.toString() ?? '';
// Fade the page content in during the last 60% of the slide-up so only
// the sheet surface and the flying Hero artwork move together; without
// this the title/controls slide separately and look detached from the
// cover. On pop/drag the content fades out first, then the cover flies.
final routeAnimation =
ModalRoute.of(context)?.animation ?? kAlwaysCompleteAnimation;
final contentOpacity = CurvedAnimation(
parent: routeAnimation,
curve: const Interval(0.4, 1.0, curve: Curves.easeOut),
);
return Scaffold(
backgroundColor: colorScheme.surface,
appBar: _FadingAppBar(
opacity: contentOpacity,
child: AppBar(
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: Text(context.l10n.nowPlayingTitle),
centerTitle: true,
leading: IconButton(
tooltip: context.l10n.nowPlayingMinimize,
icon: const Icon(Icons.keyboard_arrow_down),
onPressed: () => Navigator.of(context).maybePop(),
),
actions: [
IconButton(
tooltip: context.l10n.nowPlayingUpNext,
icon: const Icon(Icons.queue_music),
onPressed: () => _showQueueSheet(colorScheme),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'details':
_showDetailsSheet(colorScheme);
break;
case 'external':
_openExternally(source);
break;
}
},
itemBuilder: (menuContext) => [
PopupMenuItem(
value: 'details',
child: ListTile(
leading: const Icon(Icons.info_outline),
title: Text(menuContext.l10n.nowPlayingDetails),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'external',
child: ListTile(
leading: const Icon(Icons.open_in_new),
title: Text(
menuContext.l10n.nowPlayingOpenInExternalPlayer,
),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
),
body: FadeTransition(
opacity: contentOpacity,
child: SafeArea(
child: Column(
children: [
Expanded(
child: PageView(
controller: _pageController,
children: [
_playerPage(mediaItem, controller, colorScheme),
_lyricsSection(colorScheme),
],
),
),
_PageTabBar(
controller: _pageController,
colorScheme: colorScheme,
labels: [
context.l10n.nowPlayingTabPlayer,
context.l10n.nowPlayingTabLyrics,
],
),
const SizedBox(height: 8),
],
),
),
),
);
}
/// The artwork sits inside a scroll view, which wins vertical drags from
/// the route-level drag region — so the artwork hooks the route directly.
Widget _artworkDragRegion(BuildContext context, Widget child) {
final route = ModalRoute.of(context);
if (route is! NowPlayingRoute) return child;
final pageHeight = MediaQuery.sizeOf(context).height;
return GestureDetector(
onVerticalDragStart: (_) => route.startDrag(),
onVerticalDragUpdate: (details) => route.updateDrag(details, pageHeight),
onVerticalDragEnd: (details) => route.endDrag(details, pageHeight),
onVerticalDragCancel: route.cancelDrag,
child: child,
);
}
Widget _playerPage(
MediaItem mediaItem,
MusicPlayerController controller,
ColorScheme colorScheme,
) {
final playback = ref.watch(playbackStateProvider).value;
final isPlaying = playback?.playing ?? false;
final position = playback?.position ?? Duration.zero;
final duration = mediaItem.duration ?? Duration.zero;
final maxMs = duration.inMilliseconds > 0
? duration.inMilliseconds.toDouble()
: 1.0;
final posMs = position.inMilliseconds
.clamp(0, duration.inMilliseconds > 0 ? duration.inMilliseconds : 0)
.toDouble();
return LayoutBuilder(
builder: (context, constraints) {
final artSize = (constraints.maxWidth - 64).clamp(0.0, 360.0);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight - 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Center(
child: _artworkDragRegion(
context,
Hero(
tag: kNowPlayingArtworkHeroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox(
width: artSize,
height: artSize,
child: PlayerArtwork(
artUri: mediaItem.artUri?.toString(),
colorScheme: colorScheme,
cacheWidth:
(artSize *
MediaQuery.devicePixelRatioOf(context))
.round(),
),
),
),
),
),
),
const SizedBox(height: 32),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
children: [
Text(
mediaItem.title,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
mediaItem.artist ?? '',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
activeTrackColor: colorScheme.primary,
inactiveTrackColor: colorScheme.onSurface.withValues(
alpha: 0.18,
),
thumbColor: colorScheme.primary,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 7,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 16,
),
),
child: Slider(
value: posMs.clamp(0, maxMs),
max: maxMs,
onChanged: duration.inMilliseconds > 0
? (value) => controller.seek(
Duration(milliseconds: value.round()),
)
: null,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Text(
formatClock(position.inSeconds),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: Center(
child: _QualityBadge(
label: _qualityLabel(),
colorScheme: colorScheme,
),
),
),
Text(
formatClock(duration.inSeconds),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
iconSize: 44,
icon: const Icon(Icons.skip_previous),
onPressed: controller.previous,
),
const SizedBox(width: 20),
Container(
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: IconButton(
iconSize: 44,
padding: const EdgeInsets.all(12),
color: colorScheme.onPrimary,
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: () => controller.togglePlayPause(isPlaying),
),
),
const SizedBox(width: 20),
IconButton(
iconSize: 44,
icon: const Icon(Icons.skip_next),
onPressed: controller.next,
),
],
),
],
),
),
);
},
);
}
Widget _lyricsSection(ColorScheme colorScheme) {
if (_loadingMeta) {
return const Center(child: CircularProgressIndicator());
}
if (_lyrics.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lyrics_outlined,
size: 40,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
context.l10n.nowPlayingNoLyrics,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
if (_lyrics.synced) {
return _SyncedLyricsView(lyrics: _lyrics, colorScheme: colorScheme);
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Text(
_lyrics.plainText,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
height: 1.6,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
);
}
Future<void> _openExternally(String source) async {
if (source.isEmpty) return;
try {
await openFile(source);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
);
}
}
Future<void> _shuffleLibrary(MusicPlayerController controller) async {
try {
final rows = await LibraryDatabase.instance.getAll();
final media = rows
.map(LocalLibraryItem.fromJson)
.where((i) => i.filePath.trim().isNotEmpty)
.map(playableFromLocal)
.toList();
if (media.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.nowPlayingLibraryEmpty)),
);
return;
}
media.shuffle();
await controller.setShuffle(true);
await controller.playAll(media);
if (mounted) Navigator.of(context).maybePop();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.nowPlayingShuffleLibraryFailed(e.toString()),
),
),
);
}
}
void _showQueueSheet(ColorScheme colorScheme) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.9,
minChildSize: 0.4,
builder: (context, scrollController) {
return Consumer(
builder: (context, ref, _) {
final queue = ref.watch(playQueueProvider).value ?? const [];
final current = ref.watch(currentMediaItemProvider).value;
final controller = ref.read(musicPlayerControllerProvider);
final shuffleOn =
ref.watch(playbackStateProvider).value?.shuffleMode ==
AudioServiceShuffleMode.all;
final textTheme = Theme.of(context).textTheme;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 16, 8),
child: Row(
children: [
Text(
context.l10n.nowPlayingUpNext,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(),
IconButton(
tooltip: shuffleOn
? context.l10n.nowPlayingShuffleOn
: context.l10n.nowPlayingPlayInOrder,
isSelected: shuffleOn,
icon: const Icon(Icons.shuffle),
color: shuffleOn ? colorScheme.primary : null,
onPressed: () => controller.setShuffle(!shuffleOn),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: () => _shuffleLibrary(controller),
icon: const Icon(Icons.shuffle, size: 18),
label: Text(context.l10n.nowPlayingShuffleLibrary),
),
),
),
if (queue.isEmpty)
Expanded(
child: Center(
child: Text(
context.l10n.nowPlayingQueueEmpty,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
)
else
Expanded(
child: ReorderableListView.builder(
scrollController: scrollController,
padding: const EdgeInsets.fromLTRB(8, 0, 8, 24),
itemCount: queue.length,
onReorderItem: (oldIndex, newIndex) {
controller.moveQueueItem(oldIndex, newIndex);
},
proxyDecorator: (child, index, animation) {
return Material(
elevation: 4,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: child,
);
},
itemBuilder: (context, i) {
final item = queue[i];
final isCurrent = current?.id == item.id;
return ListTile(
key: ValueKey('${item.id}_$i'),
contentPadding: const EdgeInsets.only(
left: 16,
right: 4,
),
leading: Icon(
isCurrent ? Icons.equalizer : Icons.music_note,
color: isCurrent
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyLarge?.copyWith(
fontWeight: isCurrent
? FontWeight.bold
: FontWeight.normal,
color: isCurrent
? colorScheme.primary
: colorScheme.onSurface,
),
),
subtitle: Text(
item.artist ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
trailing: ReorderableDragStartListener(
index: i,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.drag_handle,
color: colorScheme.onSurfaceVariant,
),
),
),
onTap: () => controller.jumpTo(i),
);
},
),
),
],
);
},
);
},
);
},
);
}
void _showDetailsSheet(ColorScheme colorScheme) {
final meta = _metadata;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.9,
minChildSize: 0.4,
builder: (context, scrollController) {
if (meta == null) {
return Center(
child: Text(
context.l10n.nowPlayingNoMetadata,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
);
}
return _MetadataList(
meta: meta,
colorScheme: colorScheme,
scrollController: scrollController,
);
},
);
},
);
}
}
class _SyncedLyricsView extends ConsumerStatefulWidget {
final ParsedLyrics lyrics;
final ColorScheme colorScheme;
const _SyncedLyricsView({required this.lyrics, required this.colorScheme});
@override
ConsumerState<_SyncedLyricsView> createState() => _SyncedLyricsViewState();
}
class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
final ScrollController _scroll = ScrollController();
int _active = -1;
bool _userScrolling = false;
static const double _estimatedLyricExtent = 64;
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
void _maybeAutoScroll(int index) {
if (_userScrolling || index < 0 || !_scroll.hasClients) return;
final position = _scroll.position;
final target =
(index * _estimatedLyricExtent) -
(position.viewportDimension * 0.35) +
24;
final clamped = target.clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
_scroll.animateTo(
clamped.toDouble(),
duration: const Duration(milliseconds: 380),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
final position =
ref.watch(playbackStateProvider).value?.position ?? Duration.zero;
final lines = widget.lyrics.lines;
final active = LyricsParser.activeIndex(lines, position);
if (active != _active) {
_active = active;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _maybeAutoScroll(active);
});
}
return NotificationListener<UserScrollNotification>(
onNotification: (notification) {
if (notification.direction != ScrollDirection.idle) {
_userScrolling = true;
Future.delayed(const Duration(seconds: 4), () {
if (mounted) _userScrolling = false;
});
}
return false;
},
child: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.fromLTRB(24, 24, 24, 80),
itemCount: lines.length,
itemBuilder: (context, index) {
final line = lines[index];
final isActive = index == active;
final isPast = index < active;
final color = isActive
? widget.colorScheme.onSurface
: isPast
? widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
: widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.8);
final text = line.text.trim().isEmpty
? '\u00b7\u00b7\u00b7'
: line.text;
Widget content;
if (isActive && line.hasWordTiming) {
content = _wordHighlightedLine(line, position);
} else {
content = Text(
text,
textAlign: TextAlign.center,
style:
(isActive
? Theme.of(context).textTheme.headlineSmall
: Theme.of(context).textTheme.titleLarge)
?.copyWith(
height: 1.4,
fontWeight: isActive
? FontWeight.bold
: FontWeight.w500,
color: color,
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: GestureDetector(
onTap: () =>
ref.read(musicPlayerControllerProvider).seek(line.time),
child: AnimatedScale(
scale: isActive ? 1.0 : 0.96,
alignment: Alignment.center,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
child: AnimatedOpacity(
opacity: isActive ? 1.0 : (isPast ? 0.55 : 0.85),
duration: const Duration(milliseconds: 280),
child: content,
),
),
),
);
},
),
);
}
Widget _wordHighlightedLine(LyricLine line, Duration position) {
final spans = <TextSpan>[];
for (final word in line.words) {
final sung = position >= word.time;
spans.add(
TextSpan(
text: word.text,
style: TextStyle(
color: sung
? widget.colorScheme.onSurface
: widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
);
}
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
height: 1.4,
fontWeight: FontWeight.bold,
),
children: spans,
),
);
}
}
class _MetadataList extends StatelessWidget {
final Map<String, dynamic> meta;
final ColorScheme colorScheme;
final ScrollController scrollController;
const _MetadataList({
required this.meta,
required this.colorScheme,
required this.scrollController,
});
@override
Widget build(BuildContext context) {
String s(Object? v) => (v ?? '').toString();
final l10n = context.l10n;
final rows = <(String, String)>[
(l10n.editMetadataFieldTitle, s(meta['title'])),
(l10n.editMetadataFieldArtist, s(meta['artist'])),
(l10n.editMetadataFieldAlbum, s(meta['album'])),
(l10n.editMetadataFieldAlbumArtist, s(meta['album_artist'])),
(l10n.editMetadataFieldGenre, s(meta['genre'])),
(l10n.editMetadataFieldComposer, s(meta['composer'])),
(l10n.editMetadataFieldDate, s(meta['date'])),
(l10n.editMetadataFieldTrackNum, s(meta['track_number'])),
(l10n.editMetadataFieldDiscNum, s(meta['disc_number'])),
(l10n.editMetadataFieldIsrc, s(meta['isrc'])),
(l10n.editMetadataFieldLabel, s(meta['label'])),
(l10n.editMetadataFieldCopyright, s(meta['copyright'])),
(l10n.libraryFilterFormat, s(meta['format']).toUpperCase()),
(l10n.audioAnalysisCodec, s(meta['audio_codec'])),
(
l10n.audioAnalysisSampleRate,
meta['sample_rate'] != null && (meta['sample_rate'] as num? ?? 0) > 0
? '${((meta['sample_rate'] as num) / 1000).toStringAsFixed(1)} kHz'
: '',
),
(
l10n.audioAnalysisBitDepth,
(meta['bit_depth'] as num? ?? 0) > 0 ? '${meta['bit_depth']}-bit' : '',
),
].where((r) => r.$2.trim().isNotEmpty && r.$2 != '0').toList();
final textTheme = Theme.of(context).textTheme;
return ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
children: [
Card(
elevation: 0,
color: settingsGroupColor(context),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
context.l10n.nowPlayingDetails,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 16),
...rows.map(
(row) => Padding(
padding: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 4,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
row.$1,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
row.$2,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
),
),
),
],
),
),
),
],
);
}
}
class _PageTabBar extends StatelessWidget {
final PageController controller;
final ColorScheme colorScheme;
final List<String> labels;
const _PageTabBar({
required this.controller,
required this.colorScheme,
required this.labels,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
double page = 0;
if (controller.hasClients && controller.position.haveDimensions) {
page = controller.page ?? controller.initialPage.toDouble();
}
return LayoutBuilder(
builder: (context, constraints) {
final tabWidth = constraints.maxWidth / labels.length;
final indicatorWidth = (tabWidth * 0.5).clamp(28.0, 80.0);
final base =
Theme.of(context).textTheme.labelLarge ?? const TextStyle();
return SizedBox(
height: 38,
child: Stack(
children: [
Row(
children: List.generate(labels.length, (i) {
// Distance of this tab from the current page position,
// used to interpolate color/weight as the user swipes.
final t = (1.0 - (page - i).abs()).clamp(0.0, 1.0);
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => controller.animateToPage(
i,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
),
child: Center(
child: Text(
labels[i],
style: base.copyWith(
fontWeight: FontWeight.lerp(
FontWeight.w500,
FontWeight.bold,
t,
),
color: Color.lerp(
colorScheme.onSurfaceVariant.withValues(
alpha: 0.55,
),
colorScheme.primary,
t,
),
),
),
),
),
);
}),
),
// Sliding underline that tracks the swipe in real time.
Positioned(
bottom: 0,
left:
page.clamp(0, (labels.length - 1).toDouble()) *
tabWidth +
(tabWidth - indicatorWidth) / 2,
child: Container(
width: indicatorWidth,
height: 3,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
),
],
),
);
},
);
},
);
}
}
class _QualityBadge extends StatelessWidget {
final String? label;
final ColorScheme colorScheme;
const _QualityBadge({required this.label, required this.colorScheme});
@override
Widget build(BuildContext context) {
final text = label;
if (text == null || text.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.graphic_eq, size: 11, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 5),
Flexible(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontSize: 10.5,
color: colorScheme.onSurfaceVariant,
letterSpacing: 0.2,
),
),
),
],
),
);
}
}