mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
Offer default and full-screen artwork in Material and Mornye, retaining landscape layouts and full-screen Mornye motion artwork. Keep the Material header readable over bright covers and normalize invalid saved styles without resetting other settings. Validation: 144 related Flutter tests, flutter analyze, generated settings, formatter and diff checks.
302 lines
12 KiB
Dart
302 lines
12 KiB
Dart
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/theme/app_motion.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';
|
|
import 'package:spotiflac_android/widgets/player_volume_control.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,
|
|
this.fullScreenArtwork = false,
|
|
});
|
|
|
|
final MediaItem mediaItem;
|
|
final PlayerMetadataController metadata;
|
|
final PlaybackSeekPreview seekPreview;
|
|
final ColorScheme colorScheme;
|
|
final bool showLyrics;
|
|
final WidgetBuilder lyricsBuilder;
|
|
final VoidCallback onOpenQueue;
|
|
|
|
/// The page backdrop paints the cover; this area only keeps its gestures.
|
|
final bool fullScreenArtwork;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
Widget artworkAt(double artSize) => fullScreenArtwork
|
|
? const PlayerArtworkDragRegion(
|
|
child: ColoredBox(
|
|
color: Colors.transparent,
|
|
child: SizedBox.expand(),
|
|
),
|
|
)
|
|
: Center(
|
|
child: PlayerArtworkDragRegion(
|
|
child: Hero(
|
|
tag: kNowPlayingArtworkHeroTag,
|
|
// Expressive rest: paused artwork settles smaller and rounder.
|
|
child: Consumer(
|
|
builder: (context, ref, artwork) {
|
|
final resting =
|
|
!ref.watch(playbackPlayingProvider) &&
|
|
!ref.watch(playbackLoadingProvider);
|
|
final motion = AppMotion.spatialSlow(
|
|
context,
|
|
AppMotionSpec(
|
|
MediaQuery.disableAnimationsOf(context)
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 500),
|
|
Curves.easeOutBack,
|
|
),
|
|
);
|
|
return AnimatedScale(
|
|
scale: resting ? 0.9 : 1,
|
|
duration: motion.duration,
|
|
curve: motion.curve,
|
|
child: TweenAnimationBuilder<double>(
|
|
tween: Tween(end: resting ? 40 : 20),
|
|
duration: motion.duration,
|
|
curve: motion.curve,
|
|
builder: (context, radius, artwork) => ClipRRect(
|
|
borderRadius: BorderRadius.circular(radius),
|
|
child: artwork,
|
|
),
|
|
child: artwork,
|
|
),
|
|
);
|
|
},
|
|
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,
|
|
// Short phones keep the room for artwork and lyrics; the
|
|
// hardware buttons still set the volume there.
|
|
children: _metadataAndControls(
|
|
context,
|
|
showVolume: constraints.maxHeight >= 600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Title/artist, seek slider, transport buttons and volume — shared by the
|
|
/// portrait column and the landscape right pane.
|
|
List<Widget> _metadataAndControls(
|
|
BuildContext context, {
|
|
bool compact = false,
|
|
bool showVolume = true,
|
|
}) {
|
|
final fallback = AppMotionSpec(
|
|
MediaQuery.disableAnimationsOf(context)
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 350),
|
|
Curves.linear,
|
|
);
|
|
final slide = AppMotion.spatial(context, fallback);
|
|
final fade = AppMotion.effects(context, fallback);
|
|
return [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 28),
|
|
child: ClipRect(
|
|
child: AnimatedSwitcher(
|
|
duration: slide.duration,
|
|
// Finish fading the old title before revealing the next one. This
|
|
// also keeps rapid skips from stacking several readable titles.
|
|
transitionBuilder: (child, animation) => FadeTransition(
|
|
opacity: animation.drive(
|
|
CurveTween(curve: Interval(0.5, 1, curve: fade.curve)),
|
|
),
|
|
child: SlideTransition(
|
|
position: Tween(begin: const Offset(0, 0.4), end: Offset.zero)
|
|
.chain(
|
|
CurveTween(curve: Interval(0.5, 1, curve: slide.curve)),
|
|
)
|
|
.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,
|
|
),
|
|
if (showVolume) ...[
|
|
SizedBox(height: compact ? 8 : 12),
|
|
const PlayerVolumeControl(),
|
|
],
|
|
];
|
|
}
|
|
}
|