fix(player): align lyrics and compact header with timeline

This commit is contained in:
zarzet committed 2026-09-27 04:18:10 +07:00
1 parent 34cda0a802
commit 0b35b134f0
3 files changed
+176 -41

No files matched your search

+80 -35
View File
@@ -51,6 +51,7 @@ import 'package:spotiflac_android/widgets/mornye_player_background.dart';
import 'package:spotiflac_android/widgets/mornye_artwork_contrast.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/mornye_player_actions_sheet.dart';
import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
import 'package:spotiflac_android/widgets/mornye_landscape_player.dart';
@@ -66,6 +67,11 @@ final _log = AppLogger('NowPlaying');
const kNowPlayingArtworkHeroTag = 'now-playing-artwork';
const _mornyeLyricFontSize = 34.0;
const _mornyeTimelinePadding = 28.0;
const _mornyeContentInset =
_mornyeTimelinePadding + MornyePlayerSlider.horizontalInset;
const _mornyeHeaderButtonSize = 48.0;
const _mornyeCompactIconSize = 28.0;
/// Slide-up route for the full player. Supports live drag-to-dismiss: the
/// page follows the finger (via [startDrag]/[updateDrag]/[endDrag]) and
@@ -1329,7 +1335,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
fullBleed ? motionHeight : artHeight * scale,
),
const Rect.fromLTWH(
28,
_mornyeContentInset,
8,
compactCoverSize,
compactCoverSize,
@@ -1497,7 +1503,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
delegate: _PlayerHeaderLayout(
progress: progress,
compactHeight: compactHeaderHeight,
compactLeft: 28 + compactCoverSize + 12,
compactLeft: _mornyeContentInset + compactCoverSize + 12,
),
children: [
LayoutId(
@@ -1577,8 +1583,8 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
}) {
final progress = compactProgress ?? (compact ? 1.0 : 0.0);
final actionSize = compactProgress == null
? (_currentPage == 0 ? 24.0 : 28.0)
: 24 + 4 * progress;
? (_currentPage == 0 ? 24.0 : _mornyeCompactIconSize)
: 24 + (_mornyeCompactIconSize - 24) * progress;
return Builder(
builder: (context) => Row(
children: [
@@ -1640,17 +1646,20 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
color: colorScheme.onSurface,
),
Builder(
builder: (buttonContext) => IconButton(
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
color: colorScheme.onSurface,
iconSize: actionSize,
icon: const Icon(CupertinoIcons.ellipsis),
onPressed: () => _showMoreActions(
context: context,
mediaItem: mediaItem,
source: mediaItem.extras?['source']?.toString() ?? '',
colorScheme: colorScheme,
anchor: mornyeMenuAnchor(buttonContext),
builder: (buttonContext) => SizedBox.square(
dimension: _mornyeHeaderButtonSize,
child: IconButton(
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
color: colorScheme.onSurface,
iconSize: actionSize,
icon: const Icon(CupertinoIcons.ellipsis),
onPressed: () => _showMoreActions(
context: context,
mediaItem: mediaItem,
source: mediaItem.extras?['source']?.toString() ?? '',
colorScheme: colorScheme,
anchor: mornyeMenuAnchor(buttonContext),
),
),
),
),
@@ -1791,7 +1800,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
),
if (options != null)
Positioned(
left: 24,
left: context.isMornye ? _mornyeContentInset : 24,
bottom: 8,
child: _autoHidingLyricsControls(options),
),
@@ -2581,11 +2590,17 @@ class _PlayerHeaderLayout extends MultiChildLayoutDelegate {
@override
void performLayout(Size size) {
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 - 28,
maxWidth: size.width - left - 28,
minWidth: size.width - left - right,
maxWidth: size.width - left - right,
minHeight: compactHeight * progress,
),
);
@@ -2664,7 +2679,9 @@ class _PlaybackControls extends ConsumerWidget {
final position = ref.watch(playbackPositionProvider);
final elapsedSeconds = (preview ?? position).inSeconds;
return Padding(
padding: EdgeInsets.symmetric(horizontal: mornye ? 28 : 16),
padding: EdgeInsets.symmetric(
horizontal: mornye ? _mornyeTimelinePadding : 16,
),
child: Column(
children: [
SliderTheme(
@@ -2698,7 +2715,9 @@ class _PlaybackControls extends ConsumerWidget {
),
Padding(
padding: EdgeInsets.symmetric(
horizontal: mornye ? 8 : 12,
horizontal: mornye
? MornyePlayerSlider.horizontalInset
: 12,
),
child: Row(
children: [
@@ -3342,20 +3361,39 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
},
child: LayoutBuilder(
builder: (context, constraints) {
final inset = mornye ? _mornyeContentInset : 24.0;
final contentWidth = (constraints.maxWidth - inset * 2).clamp(
0.0,
double.infinity,
);
// Lay out at the existing lyric width, then fit the whole line to
// the timeline. Scaling text, phrase gaps and supplements together
// preserves wrapping instead of pushing extra words onto a new row.
final layoutWidth = (constraints.maxWidth - 48).clamp(
0.0,
double.infinity,
);
final lyricScale = mornye && layoutWidth > 0
? contentWidth / layoutWidth
: 1.0;
if (mornye) {
final previousExtents = _lineExtents;
_measureMornyeLines(
(constraints.maxWidth - 48).clamp(0, double.infinity),
);
_measureMornyeLines(layoutWidth);
// Reuse text measurements; only interpolate row heights as the
// supplements fade. Scrolling follows the same animation clock.
_lineExtents = [
for (final (primary, pronunciation, translation)
in _lineMeasurements)
primary +
pronunciation * visibility.dx +
translation * visibility.dy,
];
_lineExtents = List.generate(_lineMeasurements.length, (row) {
final (primary, pronunciation, translation) =
_lineMeasurements[row];
final index = _displayLayout.lineOrder[row];
if (_lines[index].text.isEmpty) return primary;
final padding = _linePadding(index).vertical;
return padding +
(primary -
padding +
pronunciation * visibility.dx +
translation * visibility.dy) *
lyricScale;
});
if (previousExtents != null &&
_layoutVisibility != null &&
_layoutVisibility != visibility &&
@@ -3410,12 +3448,14 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
itemExtentBuilder: mornye
? (index, _) => index < lines.length
? _lineExtents![index]
: widget.credits!.heightFor(
context,
constraints.maxWidth - 48,
)
: widget.credits!.heightFor(context, contentWidth)
: null,
padding: EdgeInsets.fromLTRB(24, topPadding, 24, bottomPadding),
padding: EdgeInsets.fromLTRB(
inset,
topPadding,
inset,
bottomPadding,
),
itemCount: lines.length + (widget.credits == null ? 0 : 1),
itemBuilder: (context, row) {
if (row == lines.length) return widget.credits!;
@@ -3557,6 +3597,11 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
child: child,
),
);
content = FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(width: layoutWidth, child: content),
);
}
return Padding(
+3 -1
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
/// A continuous player track with no thumb in either gesture state.
class MornyePlayerSlider extends StatelessWidget {
static const horizontalInset = 8.0;
const MornyePlayerSlider({
super.key,
required this.value,
@@ -34,7 +36,7 @@ class MornyePlayerSlider extends StatelessWidget {
showValueIndicator: ShowValueIndicator.never,
),
child: Slider(
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: horizontalInset),
value: value,
max: max,
activeColor: activeColor,
+93 -5
View File
@@ -6,7 +6,8 @@ import 'dart:ui' as ui;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderRepaintBoundary;
import 'package:flutter/rendering.dart'
show RenderParagraph, RenderRepaintBoundary;
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -96,6 +97,29 @@ void main() {
extras: {'source': 'content://library/$id.flac'},
);
Rect seekTrackBounds(WidgetTester tester) {
final track = find.descendant(
of: find.descendant(
of: find.byType(PlaybackSeekSlider),
matching: find.byType(Slider),
),
matching: find.byWidgetPredicate(
(widget) => widget is LeafRenderObjectWidget,
),
);
final box = tester.renderObject<RenderBox>(track);
final theme = SliderTheme.of(tester.element(track));
final rect = theme.trackShape!.getPreferredRect(
parentBox: box,
sliderTheme: theme,
isEnabled: true,
);
return Rect.fromPoints(
box.localToGlobal(rect.topLeft),
box.localToGlobal(rect.bottomRight),
);
}
Future<void> pumpNowPlaying(
WidgetTester tester, {
ThemeData? theme,
@@ -438,6 +462,61 @@ void main() {
expect(tester.takeException(), isNull);
});
for (final width in [393.0, 768.0]) {
testWidgets('Mornye lyrics fit the timeline without rewrapping ($width)', (
tester,
) async {
const original =
'A longer lyric with words that already wrap onto another row';
const pronunciation = 'Pronunciation follows the same layout as before';
const translation =
'The translated words also keep their existing line breaks';
metadataOverrides['lyrics'] =
'[x-romaji:1000:${base64.encode(utf8.encode(pronunciation))}]\n'
'[x-translation:1000:${base64.encode(utf8.encode(translation))}]\n'
'[00:01.000]$original\n[00:15.000]Next line';
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: Size(width, 1024),
playback: PlaybackState(updatePosition: const Duration(seconds: 2)),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final track = seekTrackBounds(tester);
for (final text in [original, pronunciation, translation]) {
final finder = find.text(text);
final paragraph = tester.renderObject<RenderParagraph>(finder);
final previousLayout = TextPainter(
text: paragraph.text,
textDirection: paragraph.textDirection,
textAlign: paragraph.textAlign,
textScaler: paragraph.textScaler,
locale: paragraph.locale,
)..layout(maxWidth: width - 48);
final selection = TextSelection(
baseOffset: 0,
extentOffset: text.length,
);
expect(
paragraph.getBoxesForSelection(selection).map((box) => box.toRect()),
previousLayout
.getBoxesForSelection(selection)
.map((box) => box.toRect()),
);
final bounds = tester.getRect(finder);
expect(bounds.left, closeTo(track.left, 0.01));
expect(bounds.right, closeTo(track.right, 0.01));
expect(bounds.height, lessThan(previousLayout.height));
previousLayout.dispose();
}
expect(tester.takeException(), isNull);
});
}
for (final size in [const Size(393, 852), const Size(768, 1024)]) {
for (final reducedMotion in [false, true]) {
testWidgets(
@@ -1496,10 +1575,7 @@ void main() {
expect(activeText.style?.fontWeight, FontWeight.bold);
expect(activeText.style?.fontSize, 34);
expect(tester.getTopLeft(active).dx, tester.getTopLeft(inactive).dx);
expect(
tester.getTopLeft(active).dx,
tester.getTopLeft(find.byType(ListView)).dx + 24,
);
expect(tester.getTopLeft(active).dx, seekTrackBounds(tester).left);
final activeFilters = tester.widgetList<ImageFiltered>(
find.ancestor(of: active, matching: find.byType(ImageFiltered)),
);
@@ -2140,6 +2216,18 @@ void main() {
final compactBounds = tester.getRect(header);
expect(compactBounds.left, cover.right + 12);
expect(compactBounds.center.dy, cover.center.dy);
final track = seekTrackBounds(tester);
expect(cover.left, closeTo(track.left, 0.01));
final more = find.descendant(
of: header,
matching: find.byIcon(CupertinoIcons.ellipsis),
);
expect(tester.getRect(more).right, closeTo(track.right, 0.01));
final moreButton = find.ancestor(
of: more,
matching: find.byType(IconButton),
);
expect(tester.getSize(moreButton), const Size(48, 48));
await tester.tap(toggle);
await tester.pump();
expect(tester.getRect(header), compactBounds);