Files
SpotiFLAC-Mobile/lib/widgets/material_player_layout.dart
T
zarzet 05a5613332 feat(player): add configurable full-screen cover art
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.
2026-10-17 22:27:06 +07:00

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(),
],
];
}
}