mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 23:11:45 +02:00
689 lines
29 KiB
Dart
689 lines
29 KiB
Dart
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:
|
|
PlayerArtwork.transitionCacheWidth(
|
|
context,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
// 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;
|
|
}
|