refactor(player): isolate routes, layouts and reactive controls

This commit is contained in:
zarzet committed 2026-10-05 03:27:46 +07:00
1 parent c22577eb18
commit 3047d5974c
17 files changed
+2590 -2093

No files matched your search

+5 -1
View File
@@ -124,7 +124,11 @@ void main() {
final navigator =
AppNavigationService.rootNavigatorKey.currentState;
if (navigator != null && handler.mediaItem.value != null) {
unawaited(navigator.push<void>(NowPlayingRoute()));
unawaited(
navigator.push<void>(
NowPlayingRoute(child: const NowPlayingScreen()),
),
);
}
} else {
await PlayerWidgetService.control(handler, command);
+271
View File
@@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/services/app_orientation.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
/// 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({required this.child, this.miniPlayerGeometry})
: super(
fullscreenDialog: true,
settings: const RouteSettings(name: fullPlayerRouteName),
);
final Widget child;
/// Read at dismissal so rotation and collapsing navigation cannot leave a
/// stale destination from when the player was opened.
final ({Rect surface, Rect artwork})? Function()? miniPlayerGeometry;
({Rect bounds, Widget child, bool fullBleed})? Function()? readArtwork;
bool get hasDismissArtwork => _dismissArtwork != null;
({Rect surface, Rect artwork})? _dismissTarget;
({Rect bounds, Widget child, bool fullBleed})? _dismissArtwork;
Rect? _dismissStart;
double _dismissStartValue = 1;
bool _interactiveTransition = false;
int _dragGeneration = 0;
// The player paints an opaque surface over the whole screen, including the
// status bar and bottom safe area. Once settled, the framework therefore
// keeps the pages below offstage: their glass, marquees and tickers stop
// compositing. Every drag or dismissal moves the animation off `completed`,
// which makes this route translucent again before the page is exposed.
@override
bool get opaque => true;
/// Overlay size when the pages below were last laid out, recorded while this
/// route hides them. A rotation while hidden leaves their geometry stale.
Size? _coveredOverlaySize;
Size? get _overlaySize {
final box = navigator?.overlay?.context.findRenderObject();
return box is RenderBox && box.hasSize ? box.size : null;
}
@override
void install() {
super.install();
animation!.addStatusListener((status) {
_coveredOverlaySize = status.isCompleted ? _overlaySize : null;
});
}
@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() {
_dragGeneration++;
controller?.stop();
if (!_interactiveTransition && controller != null) {
controller!.value = Easing.emphasizedDecelerate.transform(
controller!.value,
);
}
_interactiveTransition = true;
changedInternalState();
}
void updateDrag(DragUpdateDetails details, double pageHeight) {
controller?.value -= (details.primaryDelta ?? 0) / pageHeight;
}
void endDrag(DragEndDetails details, double pageHeight) {
final velocity = (details.primaryVelocity ?? 0) / pageHeight;
final value = controller?.value ?? 1.0;
if (velocity > 1.0 || (velocity >= 0 && value < 0.7)) {
navigator?.pop();
} else {
_settleOpen();
}
}
void cancelDrag() {
_settleOpen();
}
void _settleOpen() {
final generation = _dragGeneration;
// Keep the same linear position mapping until settling finishes. Switching
// to the entrance curve on release jumps away from the finger's position.
controller
?.animateTo(
1,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
)
.whenCompleteOrCancel(() {
if (generation != _dragGeneration || !isCurrent) return;
_interactiveTransition = false;
changedInternalState();
});
}
@override
bool didPop(void result) {
final context = subtreeContext;
if (context != null &&
context.isMornye &&
!MediaQuery.disableAnimationsOf(context) &&
(controller?.value ?? 0) > 0) {
final coveredSize = _coveredOverlaySize;
final target = coveredSize == null || coveredSize == _overlaySize
? miniPlayerGeometry?.call()
: null;
if (target != null && !target.surface.isEmpty) {
final size = MediaQuery.sizeOf(context);
_dismissStartValue = controller!.value;
final position = _interactiveTransition
? _dismissStartValue
: Easing.emphasizedDecelerate.transform(_dismissStartValue);
_dismissStart = Rect.fromLTWH(
0,
size.height * (1 - position),
size.width,
size.height,
);
_dismissTarget = target;
_dismissArtwork = readArtwork?.call();
}
}
return super.didPop(result);
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return _RouteDragRegion(route: this, child: child);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final target = _dismissTarget;
// Restoring portrait can resize the window during dismissal. Geometry
// captured in landscape no longer points at the mini player in that case.
if (target != null && _dismissStart?.size == MediaQuery.sizeOf(context)) {
final progress = Curves.easeInOutCubic.transform(
(1 - animation.value / _dismissStartValue).clamp(0.0, 1.0),
);
final bounds = Rect.lerp(_dismissStart, target.surface, progress)!;
final size = MediaQuery.sizeOf(context);
final cover = _dismissArtwork;
return Stack(
fit: StackFit.expand,
children: [
Positioned.fromRect(
rect: bounds,
child: ClipRRect(
key: const ValueKey('player-minimize-surface'),
borderRadius: BorderRadius.circular(28 * progress),
child: OverflowBox(
alignment: Alignment.topLeft,
minWidth: size.width,
maxWidth: size.width,
minHeight: size.height,
maxHeight: size.height,
child: Transform.scale(
scale: bounds.width / size.width,
alignment: Alignment.topLeft,
child: Opacity(
opacity: 1 - const Interval(0.15, 0.85).transform(progress),
child: child,
),
),
),
),
),
if (cover != null)
Positioned.fromRect(
rect: Rect.lerp(cover.bounds, target.artwork, progress)!,
child: ClipRRect(
key: const ValueKey('player-minimize-artwork'),
borderRadius: BorderRadius.circular(
cover.fullBleed ? 6 * progress : 12 - 6 * progress,
),
child: cover.fullBleed
? ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (bounds) => LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0, 0.58, 1],
colors: [
Colors.white,
Colors.white,
Colors.white.withValues(alpha: progress),
],
).createShader(bounds),
child: cover.child,
)
: cover.child,
),
),
],
);
}
final reversing = animation.status == AnimationStatus.reverse;
// Mornye's full-bleed artwork travels with its panel. Material keeps the
// independent cover flight back to the mini-player on button/back pop.
final inPlacePop =
reversing && !_interactiveTransition && !context.isMornye;
final slide = _interactiveTransition
? animation
: animation.drive(CurveTween(curve: Easing.emphasizedDecelerate));
final position = inPlacePop
? const AlwaysStoppedAnimation(Offset.zero)
: slide.drive(Tween(begin: const Offset(0, 1), end: Offset.zero));
final opacity = inPlacePop ? animation : kAlwaysCompleteAnimation;
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,
);
}
}
File diff suppressed because it is too large. Load diff
+231
View File
@@ -0,0 +1,231 @@
import 'dart:math' as math;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/routes/now_playing_route.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
import 'package:spotiflac_android/widgets/player_gesture_regions.dart';
import 'package:spotiflac_android/widgets/player_metadata_title.dart';
import 'package:spotiflac_android/widgets/player_playback_controls.dart';
class MaterialPlayerLayout extends StatelessWidget {
const MaterialPlayerLayout({
super.key,
required this.mediaItem,
required this.metadata,
required this.seekPreview,
required this.colorScheme,
required this.showLyrics,
required this.lyricsBuilder,
required this.onOpenQueue,
});
final MediaItem mediaItem;
final PlayerMetadataController metadata;
final PlaybackSeekPreview seekPreview;
final ColorScheme colorScheme;
final bool showLyrics;
final WidgetBuilder lyricsBuilder;
final VoidCallback onOpenQueue;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
Widget artworkAt(double artSize) => Center(
child: PlayerArtworkDragRegion(
child: Hero(
tag: kNowPlayingArtworkHeroTag,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox(
width: artSize,
height: artSize,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 450),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.94, end: 1.0).animate(animation),
child: child,
),
),
child: PlayerArtwork(
key: ValueKey(
mediaItem.artUri?.toString() ?? mediaItem.id,
),
artUri: mediaItem.artUri?.toString(),
colorScheme: colorScheme,
cacheWidth:
(artSize * MediaQuery.devicePixelRatioOf(context))
.round(),
),
),
),
),
),
),
);
Widget visual() => AnimatedSwitcher(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 300),
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
child: showLyrics
? KeyedSubtree(
key: const ValueKey('material-player-lyrics'),
child: lyricsBuilder(context),
)
: LayoutBuilder(
key: const ValueKey('material-player-cover'),
builder: (context, area) => artworkAt(
math.max(
0.0,
math.min(
360.0,
math.min(area.maxWidth - 64, area.maxHeight - 24),
),
),
),
),
);
// Tablet/landscape: artwork pane left, metadata and controls right,
// instead of one narrow column in a sea of empty space.
final twoPane =
constraints.maxWidth >= 720 &&
constraints.maxWidth > constraints.maxHeight;
if (twoPane) {
return Row(
children: [
Expanded(child: visual()),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight - 32,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: _metadataAndControls(context, compact: true),
),
),
),
),
],
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
children: [
Expanded(child: visual()),
const SizedBox(height: 12),
PlayerQueueSwipeRegion(
onOpenQueue: onOpenQueue,
child: Column(
mainAxisSize: MainAxisSize.min,
children: _metadataAndControls(context),
),
),
],
),
);
},
);
}
/// Title/artist, seek slider, and transport buttons — shared by the
/// portrait column and the landscape right pane.
List<Widget> _metadataAndControls(
BuildContext context, {
bool compact = false,
}) {
return [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween(
begin: const Offset(0, 0.15),
end: Offset.zero,
).animate(animation),
child: child,
),
),
child: Row(
key: ValueKey(mediaItem.id),
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
PlayerMetadataTitle(
mediaItem: mediaItem,
metadata: metadata,
style:
(compact
? Theme.of(context).textTheme.titleLarge
: Theme.of(context).textTheme.headlineSmall)
?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
maxLines: compact ? 1 : 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: compact ? 4 : 6),
Consumer(
builder: (context, ref, _) {
final track = ref
.watch(playerCollectionTrackProvider(mediaItem))
.value;
return ClickableArtistName(
artistName: mediaItem.artist ?? '',
artistId: track?.artistId,
extensionId: track?.source,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
],
),
),
],
),
),
),
SizedBox(height: compact ? 12 : 24),
PlayerPlaybackControls(
key: ValueKey(mediaItem.id),
mediaId: mediaItem.id,
duration: mediaItem.duration ?? Duration.zero,
seekPreview: seekPreview,
colorScheme: colorScheme,
metadata: metadata,
compact: compact,
),
];
}
}
+1
View File
@@ -114,6 +114,7 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
onTap: () {
Navigator.of(context, rootNavigator: true).push(
NowPlayingRoute(
child: const NowPlayingScreen(),
miniPlayerGeometry: mornye
? () {
if (!mounted) return null;
+690
View File
@@ -0,0 +1,690 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/routes/now_playing_route.dart';
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
import 'package:spotiflac_android/widgets/mornye_landscape_player.dart';
import 'package:spotiflac_android/widgets/mornye_player_favorite_button.dart';
import 'package:spotiflac_android/widgets/mornye_player_queue.dart';
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
import 'package:spotiflac_android/widgets/overflow_marquee.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
import 'package:spotiflac_android/widgets/player_gesture_regions.dart';
import 'package:spotiflac_android/widgets/player_lyrics.dart';
import 'package:spotiflac_android/widgets/player_lyrics_controls.dart';
import 'package:spotiflac_android/widgets/player_metadata_title.dart';
import 'package:spotiflac_android/widgets/player_playback_controls.dart';
import 'package:spotiflac_android/widgets/player_track_swipe.dart';
const _mornyeContentInset = mornyeLyricsContentInset;
const _mornyeHeaderButtonSize = 48.0;
const _mornyeCompactIconSize = 28.0;
/// Keys stay owned by the surface that measures contrast and route dismissal.
typedef MornyePlayerArtworkKeys = ({
GlobalKey header,
GlobalKey controls,
GlobalKey volume,
GlobalKey expanded,
GlobalKey compact,
});
class MornyePlayerLayout extends ConsumerWidget {
const MornyePlayerLayout({
super.key,
required this.mediaItem,
required this.metadata,
required this.seekPreview,
required this.colorScheme,
required this.page,
required this.motionArtwork,
required this.insetArtwork,
required this.artworkTopInset,
required this.artworkKeys,
required this.artworkForeground,
required this.lyricsControlsHidden,
required this.lyricsOptionsOpen,
required this.lyricsBuilder,
required this.lyricsOptions,
required this.onPageChanged,
required this.onTrackNavigation,
required this.onMoreActions,
this.artworkAspectRatio,
});
final MediaItem mediaItem;
final PlayerMetadataController metadata;
final PlaybackSeekPreview seekPreview;
final ColorScheme colorScheme;
final int page;
final Widget motionArtwork;
final double? artworkAspectRatio;
final bool insetArtwork;
final double artworkTopInset;
final MornyePlayerArtworkKeys artworkKeys;
final ValueListenable<Map<String, Color>> artworkForeground;
final bool lyricsControlsHidden;
final bool lyricsOptionsOpen;
final Widget Function(bool showOptions) lyricsBuilder;
final Widget? lyricsOptions;
final ValueChanged<int> onPageChanged;
final Future<void> Function(BuildContext, MediaItem) onTrackNavigation;
final Future<void> Function(BuildContext, MediaItem, Rect?) onMoreActions;
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(musicPlayerControllerProvider);
final showLyrics = page == 1;
final showQueue = page == 2;
final compactStage = showLyrics || showQueue;
ColorScheme foreground(String region) {
final color = compactStage
? null
: artworkForeground.value[region] ?? colorScheme.onSurface;
return color == null
? colorScheme
: colorScheme.copyWith(
onSurface: color,
onSurfaceVariant: color.withValues(alpha: 0.72),
);
}
final motion = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 380);
final queue = ref.watch(playQueueProvider).value ?? const <MediaItem>[];
final reportedIndex = ref.watch(
playbackStateProvider.select((state) => state.value?.queueIndex),
);
final currentIndex =
reportedIndex != null &&
reportedIndex >= 0 &&
reportedIndex < queue.length &&
queue[reportedIndex].id == mediaItem.id
? reportedIndex
: queue.indexWhere((item) => item.id == mediaItem.id);
final landscape =
MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
Widget hideControls(Widget child) => PlayerLyricsControlsVisibility(
hidden: showLyrics && lyricsControlsHidden && !landscape,
child: child,
);
final body = LayoutBuilder(
builder: (context, constraints) {
final screenSize = MediaQuery.sizeOf(context);
final landscape = screenSize.width > screenSize.height;
final scale = MediaQuery.textScalerOf(context).scale(17) / 17;
const compactCoverSize = 72.0;
final compactHeaderHeight = (44 * scale + 4).clamp(
compactCoverSize,
double.infinity,
);
Widget stage({
bool artworkOnly = false,
double progress = 0,
}) => LayoutBuilder(
builder: (context, stage) {
final compact = compactStage && !artworkOnly;
final expandedArtwork =
artworkOnly || (!compactStage && insetArtwork);
final artSize = (stage.maxWidth - (artworkOnly ? 56 : 48)).clamp(
0.0,
(stage.maxHeight - (artworkOnly ? 24 : 8)).clamp(0.0, 360.0),
);
final ratio = artworkAspectRatio ?? 1.0;
final artWidth = ratio < 1 ? artSize * ratio : artSize;
final artHeight = ratio > 1 ? artSize / ratio : artSize;
final fullBleed = !insetArtwork && !artworkOnly;
// Match the background's bounds even while this cover is hidden.
// Opening/closing a panel then moves the cover between the same
// two positions, with the playback controls painted above it.
final motionHeight = artworkAspectRatio != null
? (screenSize.width / ratio).clamp(
0.0,
screenSize.height * 0.75,
)
: screenSize.height * 0.66;
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: compact ? compactHeaderHeight + 8 : stage.maxHeight,
child: const PlayerTrackSwipeRegion(child: SizedBox.expand()),
),
Positioned.fill(
top: 8 + compactHeaderHeight + 16,
child: IgnorePointer(
ignoring: !compact,
child: ExcludeSemantics(
excluding: !compact,
child: AnimatedSwitcher(
duration: motion,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween(
begin: const Offset(0, 0.035),
end: Offset.zero,
).animate(animation),
child: child,
),
),
layoutBuilder: (currentChild, previousChildren) =>
Stack(
fit: StackFit.expand,
children: [
// Preserve each panel's parent and key when
// it becomes outgoing, including lyric scroll.
for (final child in [
...previousChildren,
?currentChild,
])
IgnorePointer(
key: child.key,
ignoring: child != currentChild,
child: ExcludeSemantics(
excluding: child != currentChild,
child: child,
),
),
],
),
// Keep the outgoing panel until it fades out;
// closing the queue must never substitute lyrics.
child: compact
? KeyedSubtree(
key: ValueKey(page),
child: showQueue
? MornyePlayerQueue(
colorScheme: colorScheme,
)
: _mornyeLyricsViewport(
context,
lyricsBuilder(false),
visibleHeight:
stage.minHeight -
(8 + compactHeaderHeight + 16),
),
)
: null,
),
),
),
),
Consumer(
builder: (context, ref, child) =>
TweenAnimationBuilder<double>(
tween: Tween(
end:
!insetArtwork ||
ref.watch(playbackPlayingProvider)
? 1
: 0.73,
),
duration: motion,
curve: Curves.easeInOutCubic,
builder: (context, scale, child) => Positioned.fromRect(
// Both cover layers travel to the actual resting size,
// including the smaller cover used while paused.
rect: Rect.lerp(
Rect.fromLTWH(
fullBleed
? 0
: (stage.maxWidth - artWidth * scale) / 2,
fullBleed
? -artworkTopInset
: (stage.maxHeight - artHeight * scale) / 2,
fullBleed ? stage.maxWidth : artWidth * scale,
fullBleed ? motionHeight : artHeight * scale,
),
const Rect.fromLTWH(
_mornyeContentInset,
8,
compactCoverSize,
compactCoverSize,
),
progress,
)!,
child: child!,
),
child: child,
),
// Both layers share the header's progress, so reversing a
// transition reuses the live cover instead of inserting a
// duplicate while its previous instance is still fading.
child: Stack(
fit: StackFit.expand,
children: [
for (final expanded in [true, false])
if (expanded
? !fullBleed && (!compact || progress < 1)
: compact || progress > 0)
HeroMode(
key: ValueKey(expanded),
enabled: expanded ? expandedArtwork : compact,
child: IgnorePointer(
ignoring: expanded ? !expandedArtwork : !compact,
child: FadeTransition(
opacity: AlwaysStoppedAnimation(
expanded ? 1 - progress : progress,
),
child: KeyedSubtree(
key: ValueKey(
expanded
? 'full-player-artwork'
: 'compact-player-artwork',
),
child: PlayerArtworkDragRegion(
child: Hero(
tag: kNowPlayingArtworkHeroTag,
child: expanded
? DecoratedBox(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x40000000),
blurRadius: 28,
offset: Offset(0, 16),
),
],
),
child: PlayerTransitionArtwork(
artworkKey:
artworkKeys.expanded,
child: ClipRRect(
borderRadius:
BorderRadius.circular(12),
child: motionArtwork,
),
),
)
: Semantics(
button: true,
label: context
.l10n
.nowPlayingTabPlayer,
child: GestureDetector(
behavior:
HitTestBehavior.opaque,
onTap: () => onPageChanged(0),
child: PlayerTransitionArtwork(
artworkKey:
artworkKeys.compact,
child: ClipRRect(
borderRadius:
BorderRadius.circular(
12,
),
child: PlayerArtwork(
artUri: mediaItem.artUri
?.toString(),
colorScheme: colorScheme,
cacheWidth:
(360 *
MediaQuery.devicePixelRatioOf(
context,
))
.round(),
),
),
),
),
),
),
),
),
),
),
),
],
),
),
],
);
},
);
// Portrait video must not push the metadata/transport below the
// square-cover position. Leave room above the anchored volume row.
final volumeGap = 8 + (constraints.maxHeight - 440).clamp(0.0, 24.0);
// Keep transport close to the timeline; spare height belongs above
// the volume row without moving either slider or the track header.
final transportShift = landscape
? 0.0
: ((volumeGap - 16) / 2).clamp(0.0, 8.0);
Widget controls() => AnimatedBuilder(
animation: artworkForeground,
builder: (context, _) => PlayerPlaybackControls(
key: artworkKeys.controls,
mediaId: mediaItem.id,
duration: mediaItem.duration ?? Duration.zero,
seekPreview: seekPreview,
colorScheme: foreground('controls'),
metadata: metadata,
compact: landscape,
transportTopPadding: 16 + transportShift,
),
);
if (landscape) {
return MornyeLandscapePlayer(
page: page,
isPlaying: ref.watch(playbackPlayingProvider),
controlsHeldOpen: lyricsOptionsOpen,
onPageChanged: onPageChanged,
artwork: stage(artworkOnly: true),
header: _trackHeader(context, colorScheme, compact: true),
lyrics: lyricsBuilder(true),
lyricsOptions: showLyrics ? lyricsOptions : null,
queue: MornyePlayerQueue(colorScheme: colorScheme),
controls: controls(),
volume: const MornyeVolumeControl(),
);
}
final options = showLyrics ? lyricsOptions : null;
Widget content() => TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: compactStage ? 1 : 0),
duration: motion,
curve: Curves.easeInOutCubic,
builder: (context, progress, controls) => CustomMultiChildLayout(
delegate: _PlayerHeaderLayout(
progress: progress,
compactHeight: compactHeaderHeight,
compactLeft: _mornyeContentInset + compactCoverSize + 12,
expandLyrics: showLyrics,
),
children: [
LayoutId(
id: _PlayerHeaderSlot.stage,
child: stage(progress: progress),
),
LayoutId(
id: _PlayerHeaderSlot.header,
child: KeyedSubtree(
key: const ValueKey('player-track-header'),
child: AnimatedBuilder(
key: artworkKeys.header,
animation: artworkForeground,
builder: (context, _) => _trackHeader(
context,
foreground('header'),
compactProgress: progress,
),
),
),
),
LayoutId(id: _PlayerHeaderSlot.controls, child: controls!),
if (options != null)
LayoutId(
id: _PlayerHeaderSlot.lyricsOptions,
child: hideControls(options),
),
],
),
child: hideControls(
Column(
mainAxisSize: MainAxisSize.min,
children: [
controls(),
SizedBox(height: volumeGap - transportShift),
AnimatedBuilder(
animation: artworkForeground,
builder: (context, _) => MornyeVolumeControl(
key: artworkKeys.volume,
foreground: foreground('volume').onSurface,
),
),
const SizedBox(height: 8),
],
),
),
);
// Controls keep their intrinsic height. Small portrait screens and
// large accessibility text can scroll instead of clipping the volume.
final minHeight = 480.0 + (scale - 1).clamp(0, 3) * 120;
if (constraints.maxHeight < minHeight) {
return SingleChildScrollView(
child: SizedBox(height: minHeight, child: content()),
);
}
return content();
},
);
return PlayerTrackSwipe(
queue: queue,
currentIndex: currentIndex,
onSelected: (item) async {
final latest = ref.read(playQueueProvider).value ?? const <MediaItem>[];
final index = latest.indexWhere((entry) => identical(entry, item));
if (index >= 0) await controller.jumpTo(index);
},
child: body,
);
}
Widget _trackHeader(
BuildContext context,
ColorScheme colorScheme, {
bool compact = false,
double? compactProgress,
}) {
final progress = compactProgress ?? (compact ? 1.0 : 0.0);
final actionSize = compactProgress == null
? (page == 0 ? 24.0 : _mornyeCompactIconSize)
: 24 + (_mornyeCompactIconSize - 24) * progress;
return Builder(
builder: (context) => Row(
children: [
Expanded(
child: PlayerTrackSwipeTitles(
current: mediaItem,
builder: (mediaItem) => Builder(
builder: (titleContext) => InkWell(
borderRadius: BorderRadius.circular(8),
onTap:
(mediaItem.artist ?? '').trim().isEmpty &&
(mediaItem.album ?? '').trim().isEmpty
? null
: () => onTrackNavigation(titleContext, mediaItem),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
OverflowMarquee(
resetKey: (mediaItem.id, mediaItem.title),
child: PlayerMetadataTitle(
mediaItem: mediaItem,
metadata: metadata,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 22 - 4 * progress,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
),
const SizedBox(height: 4),
OverflowMarquee(
resetKey: (mediaItem.id, mediaItem.artist),
child: Text(
mediaItem.artist ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20 - 4 * progress,
color: colorScheme.onSurface.withValues(
alpha: 0.72,
),
),
),
),
],
),
),
),
),
),
MornyePlayerFavoriteButton(
key: ValueKey(mediaItem.id),
mediaItem: mediaItem,
compact: page != 0,
iconSize: actionSize,
color: colorScheme.onSurface,
),
Builder(
builder: (buttonContext) => SizedBox.square(
dimension: _mornyeHeaderButtonSize,
child: IconButton(
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
color: colorScheme.onSurface,
iconSize: actionSize,
icon: const Icon(CupertinoIcons.ellipsis),
onPressed: () => onMoreActions(
buttonContext,
mediaItem,
mornyeMenuAnchor(buttonContext),
),
),
),
),
],
),
);
}
Widget _mornyeLyricsViewport(
BuildContext context,
Widget child, {
required double visibleHeight,
}) => TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: lyricsControlsHidden ? 1 : 0),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
builder: (context, reveal, child) => ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (bounds) {
final height = bounds.height.clamp(1.0, double.infinity);
final end = visibleHeight.clamp(0.0, height);
final start = (end - 24).clamp(0.0, end);
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, start / height, end / height, 1],
colors: [
Colors.white,
Colors.white,
Colors.white.withValues(alpha: reveal),
Colors.white.withValues(alpha: reveal),
],
).createShader(bounds);
},
child: child,
),
child: child,
);
}
enum _PlayerHeaderSlot { stage, header, controls, lyricsOptions }
/// Keep one live header throughout the cover/lyrics/queue transition. Measuring
/// it at its current width also accommodates accessibility text sizes without
/// moving the transport controls below this layout.
class _PlayerHeaderLayout extends MultiChildLayoutDelegate {
_PlayerHeaderLayout({
required this.progress,
required this.compactHeight,
required this.compactLeft,
required this.expandLyrics,
});
final double progress;
final double compactHeight;
final double compactLeft;
final bool expandLyrics;
@override
void performLayout(Size size) {
final controls = layoutChild(
_PlayerHeaderSlot.controls,
BoxConstraints.tightFor(width: size.width),
);
final contentHeight = (size.height - controls.height).clamp(
0.0,
size.height,
);
positionChild(_PlayerHeaderSlot.controls, Offset(0, contentHeight));
final left = 28 + (compactLeft - 28) * progress;
// Align the visible ellipsis with the timeline while leaving its touch
// target wider than the glyph and interpolating the same live header.
const compactRight =
_mornyeContentInset -
(_mornyeHeaderButtonSize - _mornyeCompactIconSize) / 2;
final right = 28 + (compactRight - 28) * progress;
final header = layoutChild(
_PlayerHeaderSlot.header,
BoxConstraints(
minWidth: size.width - left - right,
maxWidth: size.width - left - right,
minHeight: compactHeight * progress,
),
);
final stageHeight = (contentHeight - (header.height + 20) * (1 - progress))
.clamp(0.0, contentHeight);
layoutChild(
_PlayerHeaderSlot.stage,
// Lyrics always extend behind controls. The minimum height marks their
// unobscured area for the paint mask; hiding controls never changes either
// constraint, scroll padding or the Scrollable's identity.
BoxConstraints(
minWidth: size.width,
maxWidth: size.width,
minHeight: stageHeight,
maxHeight:
stageHeight + (expandLyrics ? controls.height * progress : 0),
),
);
positionChild(_PlayerHeaderSlot.stage, Offset.zero);
positionChild(
_PlayerHeaderSlot.header,
Offset(
left,
(contentHeight - header.height - 8) * (1 - progress) + 8 * progress,
),
);
if (hasChild(_PlayerHeaderSlot.lyricsOptions)) {
final options = layoutChild(
_PlayerHeaderSlot.lyricsOptions,
BoxConstraints.loose(size),
);
positionChild(
_PlayerHeaderSlot.lyricsOptions,
Offset(_mornyeContentInset, contentHeight - options.height - 8),
);
}
}
@override
bool shouldRelayout(_PlayerHeaderLayout oldDelegate) =>
progress != oldDelegate.progress ||
compactHeight != oldDelegate.compactHeight ||
compactLeft != oldDelegate.compactLeft ||
expandLyrics != oldDelegate.expandLyrics;
}
+177
View File
@@ -0,0 +1,177 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/isrc_utils.dart';
import 'package:spotiflac_android/widgets/mornye_player_details_sheet.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
Future<void> showPlayerDetailsSheet(
BuildContext context,
ColorScheme colorScheme, {
required PlayerMetadataController metadata,
required MediaItem? mediaItem,
}) {
final mornye = context.isMornye;
final fallback = metadata.detailsSnapshot(mediaItem);
final metadataFuture = metadata.readDetails(mediaItem);
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useRootNavigator: mornye,
showDragHandle: !mornye,
backgroundColor: mornye
? Colors.transparent
: colorScheme.surfaceContainerHigh,
builder: (context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: mornye ? 0.75 : 0.6,
maxChildSize: 0.9,
minChildSize: 0.4,
builder: (context, scrollController) {
return FutureBuilder<Map<String, dynamic>>(
future: metadataFuture,
initialData: fallback,
builder: (context, snapshot) => _MetadataList(
meta: snapshot.data ?? fallback,
colorScheme: colorScheme,
scrollController: scrollController,
mediaItem: mediaItem,
),
);
},
);
},
);
}
class _MetadataList extends StatelessWidget {
final Map<String, dynamic> meta;
final ColorScheme colorScheme;
final ScrollController scrollController;
final MediaItem? mediaItem;
const _MetadataList({
required this.meta,
required this.colorScheme,
required this.scrollController,
this.mediaItem,
});
@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, formatIsrcForDisplay(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();
if (context.isMornye) {
return MornyePlayerDetailsSheet(
rows: rows,
scrollController: scrollController,
mediaItem: mediaItem,
);
}
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,
),
),
),
],
),
),
),
],
),
),
),
],
);
}
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/routes/now_playing_route.dart';
import 'package:spotiflac_android/widgets/player_track_swipe.dart';
/// The footer opens the queue upwards and dismisses the route downwards.
class PlayerQueueSwipeRegion extends StatefulWidget {
const PlayerQueueSwipeRegion({
super.key,
required this.onOpenQueue,
required this.child,
});
final VoidCallback onOpenQueue;
final Widget child;
@override
State<PlayerQueueSwipeRegion> createState() => _PlayerQueueSwipeRegionState();
}
class _PlayerQueueSwipeRegionState extends State<PlayerQueueSwipeRegion> {
bool _forwarding = false;
double _total = 0;
@override
Widget build(BuildContext context) {
final route = ModalRoute.of(context);
final playerRoute = route is NowPlayingRoute ? route : null;
final pageHeight = MediaQuery.sizeOf(context).height;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragStart: (_) {
_forwarding = false;
_total = 0;
},
onVerticalDragUpdate: (details) {
if (_forwarding) {
playerRoute?.updateDrag(details, pageHeight);
return;
}
_total += details.primaryDelta ?? 0;
if (_total > 12 && playerRoute != null) {
playerRoute.startDrag();
_forwarding = true;
}
},
onVerticalDragEnd: (details) {
if (_forwarding) {
playerRoute?.endDrag(details, pageHeight);
} else if ((details.primaryVelocity ?? 0) < -300 || _total < -40) {
widget.onOpenQueue();
}
},
onVerticalDragCancel: () {
if (_forwarding) playerRoute?.cancelDrag();
},
child: widget.child,
);
}
}
/// Artwork forwards vertical drags inside scroll views to the player route.
class PlayerArtworkDragRegion extends StatelessWidget {
const PlayerArtworkDragRegion({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final route = ModalRoute.of(context);
if (route is! NowPlayingRoute) return PlayerTrackSwipeRegion(child: child);
final pageHeight = MediaQuery.sizeOf(context).height;
return PlayerTrackSwipeRegion(
child: GestureDetector(
onVerticalDragStart: (_) => route.startDrag(),
onVerticalDragUpdate: (details) =>
route.updateDrag(details, pageHeight),
onVerticalDragEnd: (details) => route.endDrag(details, pageHeight),
onVerticalDragCancel: route.cancelDrag,
child: child,
),
);
}
}
/// Hides the live cover while the route paints its captured dismissal cover.
class PlayerTransitionArtwork extends StatelessWidget {
const PlayerTransitionArtwork({
super.key,
required this.artworkKey,
required this.child,
});
final GlobalKey artworkKey;
final Widget child;
@override
Widget build(BuildContext context) {
final route = ModalRoute.of(context);
return AnimatedBuilder(
animation: route?.animation ?? kAlwaysCompleteAnimation,
child: child,
builder: (_, child) => Opacity(
key: artworkKey,
opacity: route is NowPlayingRoute && route.hasDismissArtwork ? 0 : 1,
child: child,
),
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
/// Fades controls without changing the lyric viewport or active scroll gesture.
class PlayerLyricsControlsVisibility extends StatelessWidget {
const PlayerLyricsControlsVisibility({
super.key,
required this.hidden,
required this.child,
});
final bool hidden;
final Widget child;
@override
Widget build(BuildContext context) => IgnorePointer(
ignoring: hidden,
child: ExcludeSemantics(
excluding: hidden,
child: AnimatedOpacity(
opacity: hidden ? 0 : 1,
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
child: child,
),
),
);
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/player_lyrics.dart';
class PlayerLyricsSection extends ConsumerWidget {
const PlayerLyricsSection({
super.key,
required this.metadata,
required this.seekPreview,
required this.colorScheme,
required this.isActive,
this.options,
});
final PlayerMetadataController metadata;
final PlaybackSeekPreview seekPreview;
final ColorScheme colorScheme;
final bool isActive;
final Widget? options;
@override
Widget build(BuildContext context, WidgetRef ref) {
final visibility = ref.watch(
settingsProvider.select(
(settings) =>
(settings.playerShowPronunciation, settings.playerShowTranslation),
),
);
return ListenableBuilder(
listenable: metadata,
builder: (context, _) => _buildLyricsSection(context, visibility),
);
}
Widget _buildLyricsSection(BuildContext context, (bool, bool) visibility) {
if (metadata.loading) {
return const Center(child: AppLoadingIndicator());
}
if (metadata.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 (metadata.lyrics.synced) {
return Stack(
fit: StackFit.expand,
children: [
SyncedLyricsView(
lyrics: metadata.lyrics,
seekPreview: seekPreview,
credits: _lyricsCredits(),
colorScheme: colorScheme,
isActive: isActive,
showPronunciation: visibility.$1,
showTranslation: visibility.$2,
),
if (options != null)
Positioned(
left: context.isMornye ? mornyeLyricsContentInset : 24,
bottom: 8,
child: options!,
),
],
);
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
metadata.lyrics.plainText,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
height: 1.6,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
?_lyricsCredits(),
],
),
);
}
LyricsCredits? _lyricsCredits() {
final (:writers, :provider) = metadata.credits;
if (writers == null && provider == null) return null;
return LyricsCredits(writers: writers, provider: provider);
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
/// File metadata can arrive after playback starts without rebuilding the layout.
class PlayerMetadataTitle extends StatelessWidget {
const PlayerMetadataTitle({
super.key,
required this.mediaItem,
required this.metadata,
this.style,
this.textAlign,
this.maxLines,
this.overflow = TextOverflow.clip,
});
final MediaItem mediaItem;
final PlayerMetadataController metadata;
final TextStyle? style;
final TextAlign? textAlign;
final int? maxLines;
final TextOverflow overflow;
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: metadata,
builder: (context, _) {
final source = mediaItem.extras?['source']?.toString();
final loaded = source == metadata.source ? metadata.metadata : null;
return ExplicitTrackTitle(
title: mediaItem.title,
explicit:
parseExplicitFlag(loaded?['explicit']) ??
parseExplicitFlag(mediaItem.extras?['explicit']) ??
false,
style: style,
textAlign: textAlign,
maxLines: maxLines,
overflow: overflow,
);
},
);
}
+369
View File
@@ -0,0 +1,369 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/automix_status_badge.dart';
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
import 'package:spotiflac_android/widgets/mornye_playback_button.dart';
import 'package:spotiflac_android/widgets/mornye_playback_time.dart';
import 'package:spotiflac_android/widgets/mornye_player_slider.dart';
import 'package:spotiflac_android/widgets/playback_seek_slider.dart';
const _mornyeTimelinePadding = 28.0;
class PlayerPlaybackControls extends ConsumerWidget {
final String mediaId;
final Duration duration;
final PlaybackSeekPreview seekPreview;
final ColorScheme colorScheme;
final PlayerMetadataController metadata;
final bool compact;
final double transportTopPadding;
const PlayerPlaybackControls({
super.key,
required this.mediaId,
required this.duration,
required this.seekPreview,
required this.colorScheme,
required this.metadata,
this.compact = false,
this.transportTopPadding = 16,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(musicPlayerControllerProvider);
final mornye = context.isMornye;
final isPlaying = ref.watch(playbackPlayingProvider);
final isLoading = ref.watch(playbackLoadingProvider);
final playPauseSize = compact
? (isPlaying ? 44.0 : 40.0)
: (isPlaying ? 60.0 : 54.0);
final timeStyle = Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant);
return Column(
children: [
ValueListenableBuilder<Duration?>(
valueListenable: seekPreview,
builder: (context, preview, _) => Consumer(
builder: (context, ref, quality) {
final position = ref.watch(playbackPositionProvider);
final elapsedSeconds = (preview ?? position).inSeconds;
return Padding(
padding: EdgeInsets.symmetric(
horizontal: mornye ? _mornyeTimelinePadding : 16,
),
child: Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
activeTrackColor: mornye
? colorScheme.onSurface
: colorScheme.primary,
inactiveTrackColor: colorScheme.onSurface.withValues(
alpha: 0.18,
),
thumbColor: mornye
? colorScheme.onSurface
: colorScheme.primary,
// A 7dp thumb was hard to grab; 10dp with a 24dp overlay
// gives the drag gesture a full-size target.
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 10,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 24,
),
),
child: PlaybackSeekSlider(
key: ValueKey(mediaId),
position: position,
duration: duration,
onSeek: controller.seek,
preview: seekPreview,
playing: isPlaying && !isLoading,
),
),
if (!mornye) const SizedBox(height: 8),
Padding(
padding: EdgeInsets.symmetric(
horizontal: mornye
? MornyePlayerSlider.horizontalInset
: 12,
),
child: Row(
children: [
if (mornye)
MornyePlaybackTime(
key: ValueKey('elapsed:$mediaId'),
seconds: elapsedSeconds,
style: timeStyle,
)
else
Text(formatClock(elapsedSeconds), style: timeStyle),
Expanded(child: Center(child: quality)),
if (mornye)
MornyePlaybackTime(
key: ValueKey('remaining:$mediaId'),
// Subtract whole seconds so both labels roll together,
// even when the track duration includes milliseconds.
seconds: (duration.inSeconds - elapsedSeconds)
.clamp(0, duration.inSeconds),
remaining: true,
style: timeStyle,
)
else
Text(
formatClock(duration.inSeconds),
style: timeStyle,
),
],
),
),
],
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_QualityBadge(metadata: metadata, colorScheme: colorScheme),
const AutoMixStatusBadge(),
],
),
),
),
SizedBox(height: compact ? 8 : transportTopPadding + (mornye ? 0 : 8)),
Padding(
padding: EdgeInsets.symmetric(
horizontal: mornye ? (compact ? 16 : 32) : 0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (!mornye)
PlayerPlaybackModeButton(
mode: PlayerPlaybackMode.shuffle,
colorScheme: colorScheme,
),
if (mornye)
MornyePlaybackButton(
icon: CupertinoIcons.backward_fill,
color: colorScheme.onSurface,
tooltip: context.l10n.nowPlayingPreviousTrack,
onPressed: controller.previous,
)
else
ExpressiveIconButton(
iconSize: 44,
foregroundColor: colorScheme.onSurface,
tooltip: context.l10n.nowPlayingPreviousTrack,
icon: const Icon(Icons.skip_previous),
onPressed: controller.previous,
),
if (mornye)
MornyePlaybackButton(
icon: isPlaying
? CupertinoIcons.pause_fill
: CupertinoIcons.play_fill,
tooltip: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
color: colorScheme.onSurface,
iconSize: playPauseSize,
// A smaller play glyph keeps the same 68dp touch target and
// does not move the timeline, artwork or adjacent buttons.
padding: EdgeInsets.all((68 - playPauseSize) / 2),
loading: isLoading,
onPressed: () => controller.togglePlayPause(isPlaying),
)
else
ExpressiveIconButton(
size: 68,
iconSize: 44,
selected: isPlaying,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
tooltip: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
icon: isLoading
? AppLoadingIndicator(color: colorScheme.onSurfaceVariant)
: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: isLoading
? null
: () => controller.togglePlayPause(isPlaying),
),
if (mornye)
MornyePlaybackButton(
icon: CupertinoIcons.forward_fill,
color: colorScheme.onSurface,
tooltip: context.l10n.nowPlayingNextTrack,
onPressed: controller.next,
)
else
ExpressiveIconButton(
iconSize: 44,
foregroundColor: colorScheme.onSurface,
tooltip: context.l10n.nowPlayingNextTrack,
icon: const Icon(Icons.skip_next),
onPressed: controller.next,
),
if (!mornye)
PlayerPlaybackModeButton(
mode: PlayerPlaybackMode.repeat,
colorScheme: colorScheme,
),
],
),
),
],
);
}
}
enum PlayerPlaybackMode { autoplay, repeat, shuffle }
/// Queue and transport buttons share behavior while keeping independent watches.
class PlayerPlaybackModeButton extends ConsumerWidget {
const PlayerPlaybackModeButton({
super.key,
required this.mode,
required this.colorScheme,
this.secondary = false,
});
final PlayerPlaybackMode mode;
final ColorScheme colorScheme;
final bool secondary;
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(musicPlayerControllerProvider);
late final bool selected;
late final IconData icon;
late final String tooltip;
late final VoidCallback onPressed;
switch (mode) {
case PlayerPlaybackMode.autoplay:
selected = ref.watch(
settingsProvider.select((settings) => settings.autoplay),
);
icon = Icons.all_inclusive;
tooltip = selected ? context.l10n.autoplayOn : context.l10n.autoplayOff;
onPressed = () =>
ref.read(settingsProvider.notifier).setAutoplay(!selected);
case PlayerPlaybackMode.shuffle:
selected = ref.watch(
playbackStateProvider.select(
(state) => state.value?.shuffleMode == AudioServiceShuffleMode.all,
),
);
icon = Icons.shuffle;
tooltip = selected
? context.l10n.nowPlayingShuffleOn
: context.l10n.nowPlayingPlayInOrder;
onPressed = () => controller.setShuffle(!selected);
case PlayerPlaybackMode.repeat:
final repeatMode = ref.watch(
playbackStateProvider.select(
(state) => state.value?.repeatMode ?? AudioServiceRepeatMode.none,
),
);
selected = repeatMode != AudioServiceRepeatMode.none;
icon = repeatMode == AudioServiceRepeatMode.one
? Icons.repeat_one
: Icons.repeat;
tooltip = switch (repeatMode) {
AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne,
AudioServiceRepeatMode.none => context.l10n.nowPlayingRepeatOff,
_ => context.l10n.nowPlayingRepeatAll,
};
onPressed = () => controller.setRepeatMode(switch (repeatMode) {
AudioServiceRepeatMode.none => AudioServiceRepeatMode.all,
AudioServiceRepeatMode.all => AudioServiceRepeatMode.one,
_ => AudioServiceRepeatMode.none,
});
}
return ExpressiveIconButton(
iconSize: 24,
selected: selected,
tooltip: tooltip,
icon: Icon(icon),
foregroundColor: selected
? (secondary ? colorScheme.onSecondaryContainer : colorScheme.primary)
: colorScheme.onSurfaceVariant,
backgroundColor: selected
? (secondary
? colorScheme.secondaryContainer
: colorScheme.primaryContainer)
: null,
onPressed: onPressed,
);
}
}
class _QualityBadge extends StatelessWidget {
final PlayerMetadataController metadata;
final ColorScheme colorScheme;
const _QualityBadge({required this.metadata, required this.colorScheme});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: metadata,
builder: (context, _) => _buildBadge(context),
);
Widget _buildBadge(BuildContext context) {
final text = metadata.qualityLabel;
if (text == null || text.isEmpty) return const SizedBox.shrink();
final mornye = context.isMornye;
return Container(
padding: EdgeInsets.symmetric(
horizontal: mornye ? 8 : 10,
vertical: mornye ? 2 : 5,
),
decoration: mornye
? null
: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.graphic_eq,
size: mornye ? 11 : 14,
color: colorScheme.onSurfaceVariant,
),
SizedBox(width: mornye ? 5 : 6),
Flexible(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontSize: mornye ? 10.5 : 12,
color: colorScheme.onSurfaceVariant,
letterSpacing: 0.2,
),
),
),
],
),
);
}
}
+212
View File
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
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/widgets/expressive_button.dart';
import 'package:spotiflac_android/widgets/player_playback_controls.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
import 'package:spotiflac_android/widgets/player_queue_dismissible.dart';
Future<void> showPlayerQueueSheet(
BuildContext context,
ColorScheme colorScheme,
) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.9,
minChildSize: 0.4,
builder: (context, scrollController) => PlayerQueueSheet(
colorScheme: colorScheme,
scrollController: scrollController,
),
),
);
class PlayerQueueSheet extends ConsumerWidget {
const PlayerQueueSheet({
super.key,
required this.colorScheme,
required this.scrollController,
});
final ColorScheme colorScheme;
final ScrollController scrollController;
@override
Widget build(BuildContext context, WidgetRef ref) {
final queue = ref.watch(playQueueProvider).value ?? const [];
final current = ref.watch(currentMediaItemProvider).value;
final controller = ref.read(musicPlayerControllerProvider);
final textTheme = Theme.of(context).textTheme;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 16, 8),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.nowPlayingUpNext,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
),
PlayerPlaybackModeButton(
mode: PlayerPlaybackMode.autoplay,
colorScheme: colorScheme,
secondary: true,
),
const SizedBox(width: 4),
PlayerPlaybackModeButton(
mode: PlayerPlaybackMode.repeat,
colorScheme: colorScheme,
secondary: true,
),
const SizedBox(width: 4),
PlayerPlaybackModeButton(
mode: PlayerPlaybackMode.shuffle,
colorScheme: colorScheme,
secondary: true,
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: SizedBox(
width: double.infinity,
child: ExpressiveButton(
tonal: true,
onPressed: () => _shuffleLibrary(context, controller),
icon: const Icon(Icons.shuffle, size: 18),
child: 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 PlayerQueueDismissible(
key: ObjectKey(item),
enabled: !isCurrent,
onRemove: () => controller.removeQueuedItem(item),
child: ListTile(
selected: isCurrent,
selectedTileColor: colorScheme.secondaryContainer
.withValues(alpha: 0.55),
contentPadding: const EdgeInsets.only(left: 16, right: 4),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox.square(
dimension: 44,
child: PlayerArtwork(
artUri: item.artUri?.toString(),
colorScheme: colorScheme,
cacheWidth: 132,
iconSize: 22,
),
),
),
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),
),
);
},
),
),
],
);
}
Future<void> _shuffleLibrary(
BuildContext context,
MusicPlayerController controller,
) async {
try {
if (!await controller.shuffleLibrary()) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.nowPlayingLibraryEmpty)),
);
return;
}
if (context.mounted) Navigator.of(context).maybePop();
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.nowPlayingShuffleLibraryFailed(
context.friendlyError(e),
),
),
),
);
}
}
}
+2
View File
@@ -5,6 +5,8 @@ import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/services/app_orientation.dart';
class _PlayerRoute extends NowPlayingRoute {
_PlayerRoute() : super(child: const SizedBox());
@override
Widget buildPage(
BuildContext context,
+3 -2
View File
@@ -2338,8 +2338,9 @@ void main() {
body: Align(
alignment: Alignment.bottomLeft,
child: TextButton(
onPressed: () =>
Navigator.of(context).push(NowPlayingRoute()),
onPressed: () => Navigator.of(
context,
).push(NowPlayingRoute(child: const NowPlayingScreen())),
child: const Hero(
tag: kNowPlayingArtworkHeroTag,
child: SizedBox.square(dimension: 38, child: Text('Open')),
+2 -2
View File
@@ -12,7 +12,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart';
const _sheet = Key('player-sheet');
class _TestPlayerRoute extends NowPlayingRoute {
_TestPlayerRoute({super.miniPlayerGeometry});
_TestPlayerRoute({super.miniPlayerGeometry}) : super(child: const SizedBox());
@override
Widget buildPage(
@@ -170,7 +170,7 @@ void main() {
),
),
);
final route = NowPlayingRoute();
final route = NowPlayingRoute(child: const NowPlayingScreen());
navigator.currentState!.push(route);
await tester.pumpAndSettle();
+199
View File
@@ -0,0 +1,199 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/routes/now_playing_route.dart';
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/material_player_layout.dart';
import 'package:spotiflac_android/widgets/player_details_sheet.dart';
import 'package:spotiflac_android/widgets/player_gesture_regions.dart';
MediaItem _item(String id, {String? source}) => MediaItem(
id: id,
title: id,
artist: 'Artist',
album: 'Album',
duration: const Duration(minutes: 3),
extras: {'source': source ?? '/music/$id.flac'},
);
class _FooterRoute extends NowPlayingRoute {
_FooterRoute(this.onOpenQueue) : super(child: const SizedBox());
final VoidCallback onOpenQueue;
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) => PlayerQueueSwipeRegion(
onOpenQueue: onOpenQueue,
child: const ColoredBox(
key: ValueKey('gesture-player'),
color: Colors.black,
),
);
}
void main() {
testWidgets('metadata and mode changes leave the player visual intact', (
tester,
) async {
final read = Completer<Map<String, dynamic>>();
final metadata = PlayerMetadataController(readMetadata: (_) => read.future);
final seekPreview = PlaybackSeekPreview();
final playback = StreamController<PlaybackState>.broadcast();
addTearDown(metadata.dispose);
addTearDown(seekPreview.dispose);
addTearDown(playback.close);
final item = _item('First');
final pending = metadata.load(item);
var visualBuilds = 0;
await tester.pumpWidget(
ProviderScope(
overrides: [
playbackStateProvider.overrideWith((ref) => playback.stream),
playerCollectionTrackProvider.overrideWith(
(ref, item) async => Track(
id: item.id,
name: item.title,
artistName: item.artist ?? '',
albumName: item.album ?? '',
duration: 180,
source: 'local',
),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: MaterialPlayerLayout(
mediaItem: item,
metadata: metadata,
seekPreview: seekPreview,
colorScheme: Theme.of(context).colorScheme,
showLyrics: true,
lyricsBuilder: (_) {
visualBuilds++;
return const SizedBox.expand();
},
onOpenQueue: () {},
),
),
),
),
),
);
await tester.pumpAndSettle();
final beforeMetadata = visualBuilds;
read.complete({
'explicit': true,
'format': 'FLAC',
'sample_rate': 48000,
'bit_depth': 24,
});
await pending;
await tester.pumpAndSettle();
expect(visualBuilds, beforeMetadata);
expect(
tester
.widget<ExplicitTrackTitle>(find.byType(ExplicitTrackTitle))
.explicit,
isTrue,
);
const quality = 'FLAC · 24-bit · 48 kHz';
final qualityWidget = tester.widget<Text>(find.text(quality));
playback.add(
PlaybackState(
processingState: AudioProcessingState.ready,
updatePosition: const Duration(seconds: 45),
shuffleMode: AudioServiceShuffleMode.all,
repeatMode: AudioServiceRepeatMode.one,
),
);
await tester.pump();
await tester.pump();
expect(visualBuilds, beforeMetadata);
expect(find.text('0:45'), findsOneWidget);
expect(
identical(tester.widget<Text>(find.text(quality)), qualityWidget),
isTrue,
);
expect(tester.takeException(), isNull);
});
testWidgets('details retain their captured item when playback changes', (
tester,
) async {
final read = Completer<Map<String, dynamic>>();
final metadata = PlayerMetadataController(
readMetadata: (path) => path.startsWith('content://')
? read.future
: Future.value({'title': 'Second tags'}),
);
addTearDown(metadata.dispose);
final first = _item('First', source: 'content://library/first.flac');
await metadata.load(first);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => showPlayerDetailsSheet(
context,
Theme.of(context).colorScheme,
metadata: metadata,
mediaItem: first,
),
child: const Text('Details'),
),
),
),
),
);
await tester.tap(find.text('Details'));
await tester.pumpAndSettle();
expect(find.text('First'), findsOneWidget);
await metadata.load(_item('Second'));
read.complete({'title': 'First tags'});
await tester.pumpAndSettle();
expect(find.text('First tags'), findsOneWidget);
expect(find.text('Second tags'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('footer opens the queue upwards and dismisses downwards', (
tester,
) async {
final navigator = GlobalKey<NavigatorState>();
var queueOpens = 0;
await tester.pumpWidget(
MaterialApp(navigatorKey: navigator, home: const SizedBox()),
);
navigator.currentState!.push(_FooterRoute(() => queueOpens++));
await tester.pumpAndSettle();
final footer = find.byKey(const ValueKey('gesture-player'));
await tester.fling(footer, const Offset(0, -80), 600);
await tester.pumpAndSettle();
expect(queueOpens, 1);
expect(footer, findsOneWidget);
await tester.fling(footer, const Offset(0, 240), 1000);
await tester.pumpAndSettle();
expect(queueOpens, 1);
expect(footer, findsNothing);
expect(tester.takeException(), isNull);
});
}