mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
refactor(player): isolate routes, layouts and reactive controls
This commit is contained in:
1 parent
c22577eb18
commit
3047d5974c
17 files changed
+2590
-2093
No files matched your search
+5
-1
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+133
-2088
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user