mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
fix(mornye): keep lyrics scrolling stable when controls fade
This commit is contained in:
1 parent
70f8bd7f35
commit
def4536d0a
2 files changed
+227
-132
No files matched your search
@@ -1181,38 +1181,57 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
_scheduleLyricsControlsHide();
|
||||
}
|
||||
|
||||
Duration get _lyricsControlsMotion => MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 220);
|
||||
|
||||
Widget _autoHidingLyricsControls(Widget child) {
|
||||
if (!context.isMornye) return child;
|
||||
final hidden = _currentPage == 1 && _lyricsControlsHidden && !_landscape;
|
||||
final motion = MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 380);
|
||||
final content = ClipRect(
|
||||
child: Align(
|
||||
heightFactor: hidden ? 0 : 1,
|
||||
child: IgnorePointer(
|
||||
ignoring: hidden,
|
||||
child: ExcludeSemantics(
|
||||
excluding: hidden,
|
||||
child: AnimatedOpacity(
|
||||
opacity: hidden ? 0 : 1,
|
||||
duration: motion,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
// Fade in place: resizing controls also resizes the lyric viewport and can
|
||||
// interrupt its active drag or trigger a playback auto-scroll correction.
|
||||
return IgnorePointer(
|
||||
ignoring: hidden,
|
||||
child: ExcludeSemantics(
|
||||
excluding: hidden,
|
||||
child: AnimatedOpacity(
|
||||
opacity: hidden ? 0 : 1,
|
||||
duration: _lyricsControlsMotion,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
return motion == Duration.zero
|
||||
? content
|
||||
: AnimatedSize(
|
||||
duration: motion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mornyeLyricsViewport(Widget child, {required double visibleHeight}) =>
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: _lyricsControlsHidden ? 1 : 0),
|
||||
duration: _lyricsControlsMotion,
|
||||
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,
|
||||
);
|
||||
|
||||
Widget _mornyePlayerPage(
|
||||
MediaItem mediaItem,
|
||||
MusicPlayerController controller,
|
||||
@@ -1370,9 +1389,15 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
? MornyePlayerQueue(
|
||||
colorScheme: colorScheme,
|
||||
)
|
||||
: _lyricsSection(
|
||||
colorScheme,
|
||||
isActive: showLyrics,
|
||||
: _mornyeLyricsViewport(
|
||||
_lyricsSection(
|
||||
colorScheme,
|
||||
isActive: showLyrics,
|
||||
showOptions: false,
|
||||
),
|
||||
visibleHeight:
|
||||
stage.minHeight -
|
||||
(8 + compactHeaderHeight + 16),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -1558,68 +1583,70 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget content() => Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: compactStage ? 1 : 0),
|
||||
duration: motion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
builder: (context, progress, _) => CustomMultiChildLayout(
|
||||
delegate: _PlayerHeaderLayout(
|
||||
progress: progress,
|
||||
compactHeight: compactHeaderHeight,
|
||||
compactLeft: _mornyeContentInset + compactCoverSize + 12,
|
||||
final lyricsOptions = showLyrics
|
||||
? _lyricsOptionsButton(colorScheme)
|
||||
: 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: _artworkHeaderKey,
|
||||
animation: _artworkColorsChanged,
|
||||
builder: (context, _) => _trackHeader(
|
||||
mediaItem,
|
||||
foreground('header'),
|
||||
compactProgress: progress,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
LayoutId(
|
||||
id: _PlayerHeaderSlot.stage,
|
||||
child: stage(progress: progress),
|
||||
),
|
||||
LayoutId(
|
||||
id: _PlayerHeaderSlot.header,
|
||||
child: KeyedSubtree(
|
||||
key: const ValueKey('player-track-header'),
|
||||
child: AnimatedBuilder(
|
||||
key: _artworkHeaderKey,
|
||||
animation: _artworkColorsChanged,
|
||||
builder: (context, _) => _trackHeader(
|
||||
mediaItem,
|
||||
foreground('header'),
|
||||
compactProgress: progress,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_autoHidingLyricsControls(
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
controls(),
|
||||
SizedBox(height: volumeGap - transportShift),
|
||||
AnimatedBuilder(
|
||||
animation: _artworkColorsChanged,
|
||||
builder: (context, _) => MornyeVolumeControl(
|
||||
key: _artworkVolumeKey,
|
||||
foreground: foreground('volume').onSurface,
|
||||
),
|
||||
LayoutId(id: _PlayerHeaderSlot.controls, child: controls!),
|
||||
if (lyricsOptions != null)
|
||||
LayoutId(
|
||||
id: _PlayerHeaderSlot.lyricsOptions,
|
||||
child: _autoHidingLyricsControls(lyricsOptions),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _autoHidingLyricsControls(
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
controls(),
|
||||
SizedBox(height: volumeGap - transportShift),
|
||||
AnimatedBuilder(
|
||||
animation: _artworkColorsChanged,
|
||||
builder: (context, _) => MornyeVolumeControl(
|
||||
key: _artworkVolumeKey,
|
||||
foreground: foreground('volume').onSurface,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
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 =
|
||||
(showLyrics && _lyricsControlsHidden ? 250.0 : 480.0) +
|
||||
(scale - 1).clamp(0, 3) * 120;
|
||||
final minHeight = 480.0 + (scale - 1).clamp(0, 3) * 120;
|
||||
if (constraints.maxHeight < minHeight) {
|
||||
return SingleChildScrollView(
|
||||
child: SizedBox(height: minHeight, child: content()),
|
||||
@@ -1820,7 +1847,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
];
|
||||
}
|
||||
|
||||
Widget _lyricsSection(ColorScheme colorScheme, {required bool isActive}) {
|
||||
Widget _lyricsSection(
|
||||
ColorScheme colorScheme, {
|
||||
required bool isActive,
|
||||
bool showOptions = true,
|
||||
}) {
|
||||
if (_loadingMeta) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
@@ -1854,7 +1885,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
final options = isActive && !_landscape
|
||||
final options = showOptions && isActive && !_landscape
|
||||
? _lyricsOptionsButton(colorScheme)
|
||||
: null;
|
||||
return Stack(
|
||||
@@ -2675,7 +2706,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
enum _PlayerHeaderSlot { stage, header }
|
||||
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
|
||||
@@ -2685,14 +2716,25 @@ class _PlayerHeaderLayout extends MultiChildLayoutDelegate {
|
||||
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.
|
||||
@@ -2708,27 +2750,47 @@ class _PlayerHeaderLayout extends MultiChildLayoutDelegate {
|
||||
minHeight: compactHeight * progress,
|
||||
),
|
||||
);
|
||||
final stageHeight = (size.height - (header.height + 20) * (1 - progress))
|
||||
.clamp(0.0, size.height);
|
||||
final stageHeight = (contentHeight - (header.height + 20) * (1 - progress))
|
||||
.clamp(0.0, contentHeight);
|
||||
layoutChild(
|
||||
_PlayerHeaderSlot.stage,
|
||||
BoxConstraints.tight(Size(size.width, stageHeight)),
|
||||
// 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,
|
||||
(size.height - header.height - 8) * (1 - progress) + 8 * progress,
|
||||
(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;
|
||||
compactLeft != oldDelegate.compactLeft ||
|
||||
expandLyrics != oldDelegate.expandLyrics;
|
||||
}
|
||||
|
||||
class _PlaybackControls extends ConsumerWidget {
|
||||
|
||||
@@ -816,50 +816,83 @@ void main() {
|
||||
expect(bar.title, isNull);
|
||||
});
|
||||
|
||||
testWidgets('manual lyric scrolling hides controls down and restores them up', (
|
||||
tester,
|
||||
) async {
|
||||
metadataOverrides = {
|
||||
'lyrics': List.generate(
|
||||
20,
|
||||
(index) =>
|
||||
'[00:${(index * 3).toString().padLeft(2, '0')}.00]Lyric $index with several words on this line',
|
||||
).join('\n'),
|
||||
};
|
||||
await pumpNowPlaying(
|
||||
tester,
|
||||
theme: MornyeTheme.build(Brightness.dark),
|
||||
size: const Size(393, 780),
|
||||
playback: PlaybackState(updatePosition: const Duration(seconds: 30)),
|
||||
for (final height in [780.0, 520.0]) {
|
||||
testWidgets(
|
||||
'manual lyric scrolling keeps its viewport stable while controls animate (height: $height)',
|
||||
(tester) async {
|
||||
metadataOverrides['lyrics'] = List.generate(
|
||||
20,
|
||||
(index) =>
|
||||
'[00:${(index * 3).toString().padLeft(2, '0')}.00]Lyric $index with several words on this line',
|
||||
).join('\n');
|
||||
await pumpNowPlaying(
|
||||
tester,
|
||||
theme: MornyeTheme.build(Brightness.dark),
|
||||
size: Size(393, height),
|
||||
playback: PlaybackState(updatePosition: const Duration(seconds: 30)),
|
||||
);
|
||||
mediaItems.add(item('many'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
|
||||
await tester.pumpAndSettle();
|
||||
final list = find.byType(ListView);
|
||||
final bounds = tester.getRect(list);
|
||||
final scrollable = find.descendant(
|
||||
of: list,
|
||||
matching: find.byType(Scrollable),
|
||||
);
|
||||
final state = tester.state<ScrollableState>(scrollable);
|
||||
final initialOffset = state.position.pixels;
|
||||
final headerTop = tester.getTopLeft(find.text('Second')).dy;
|
||||
final transport = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is MornyePlaybackButton &&
|
||||
const [
|
||||
CupertinoIcons.play_fill,
|
||||
CupertinoIcons.backward_fill,
|
||||
CupertinoIcons.forward_fill,
|
||||
].contains(widget.icon),
|
||||
);
|
||||
expect(transport.hitTestable(), findsNWidgets(3));
|
||||
final touch = await tester.startGesture(
|
||||
Offset(bounds.center.dx, bounds.top + 100),
|
||||
);
|
||||
await touch.moveBy(const Offset(0, -20));
|
||||
await tester.pump();
|
||||
await touch.moveBy(const Offset(0, -60));
|
||||
await tester.pump();
|
||||
final hiddenOffset = state.position.pixels;
|
||||
expect(hiddenOffset, greaterThan(initialOffset));
|
||||
expect(transport.hitTestable(), findsNothing);
|
||||
for (var frame = 0; frame < 3; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
expect(tester.getRect(list), bounds);
|
||||
expect(tester.state<ScrollableState>(scrollable), same(state));
|
||||
expect(state.position.pixels, closeTo(hiddenOffset, 0.01));
|
||||
}
|
||||
|
||||
// Continue the same drag, then reverse before the hide completes.
|
||||
await touch.moveBy(const Offset(0, -32));
|
||||
await tester.pump();
|
||||
expect(state.position.pixels, closeTo(hiddenOffset + 32, 0.01));
|
||||
await touch.moveBy(const Offset(0, 48));
|
||||
await tester.pump();
|
||||
final restoredOffset = state.position.pixels;
|
||||
expect(restoredOffset, closeTo(hiddenOffset - 16, 0.01));
|
||||
for (var frame = 0; frame < 10; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
expect(tester.getRect(list), bounds);
|
||||
expect(tester.state<ScrollableState>(scrollable), same(state));
|
||||
expect(state.position.pixels, closeTo(restoredOffset, 0.01));
|
||||
}
|
||||
expect(tester.getTopLeft(find.text('Second')).dy, headerTop);
|
||||
expect(transport.hitTestable(), findsNWidgets(3));
|
||||
await touch.up();
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
mediaItems.add(item('many'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
|
||||
await tester.pumpAndSettle();
|
||||
final list = find.byType(ListView);
|
||||
final initialHeight = tester.getSize(list).height;
|
||||
final headerTop = tester.getTopLeft(find.text('Second')).dy;
|
||||
final transport = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is MornyePlaybackButton &&
|
||||
const [
|
||||
CupertinoIcons.play_fill,
|
||||
CupertinoIcons.backward_fill,
|
||||
CupertinoIcons.forward_fill,
|
||||
].contains(widget.icon),
|
||||
);
|
||||
expect(transport.hitTestable(), findsNWidgets(3));
|
||||
await tester.drag(list, const Offset(0, -140));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(list).height, greaterThan(initialHeight + 100));
|
||||
expect(tester.getTopLeft(find.text('Second')).dy, closeTo(headerTop, 1));
|
||||
expect(transport.hitTestable(), findsNothing);
|
||||
await tester.drag(list, const Offset(0, 140));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(list).height, closeTo(initialHeight, 1));
|
||||
expect(transport.hitTestable(), findsNWidgets(3));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final reducedMotion in [false, true]) {
|
||||
testWidgets(
|
||||
@@ -910,7 +943,7 @@ void main() {
|
||||
expect(volume.hitTestable(), findsNothing);
|
||||
expect(lyricsButton.hitTestable(), findsNothing);
|
||||
expect(queueButton.hitTestable(), findsNothing);
|
||||
expect(tester.getSize(list).height, greaterThan(originalHeight + 150));
|
||||
expect(tester.getSize(list).height, originalHeight);
|
||||
expect(
|
||||
find.byKey(const ValueKey('player-track-header')).hitTestable(),
|
||||
findsOneWidget,
|
||||
|
||||
Reference in new issue
Block a user