mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
fix(player): align lyrics and compact header with timeline
This commit is contained in:
1 parent
34cda0a802
commit
0b35b134f0
3 files changed
+176
-41
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user