fix(player): prevent overlapping titles during rapid skips

This commit is contained in:
zarzet committed 2026-10-14 23:05:08 +07:00
1 parent 8d1a42e01a
commit 97d8ee3695
2 files changed
+108 -64

No files matched your search

+68 -64
View File
@@ -7,7 +7,6 @@ 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/theme/material_expressive.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';
@@ -183,75 +182,80 @@ class MaterialPlayerLayout extends StatelessWidget {
BuildContext context, {
bool compact = false,
}) {
final expressive = materialExpressiveEnabled(context);
const fallback = AppMotionSpec(Duration(milliseconds: 350), Curves.linear);
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: AnimatedSwitcher(
duration: slide.duration,
// Expressive: a spatial spring moves the title while an effects
// spring fades it, so opacity never overshoots.
switchInCurve: expressive ? Curves.linear : Curves.easeOutCubic,
switchOutCurve: expressive ? Curves.linear : Curves.easeInCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: expressive
? animation.drive(CurveTween(curve: fade.curve))
: animation,
child: SlideTransition(
position: Tween(
begin: const Offset(0, 0.15),
end: Offset.zero,
).chain(CurveTween(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,
);
},
),
],
),
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,
);
},
),
],
),
),
],
),
),
),
),
@@ -46,6 +46,8 @@ import 'package:spotiflac_android/widgets/mornye_player_background.dart';
import 'package:spotiflac_android/widgets/mornye_player_artwork.dart';
import 'package:spotiflac_android/widgets/mornye_artwork_contrast.dart';
import 'package:spotiflac_android/widgets/mini_player.dart';
import 'package:spotiflac_android/widgets/material_player_layout.dart';
import 'package:spotiflac_android/widgets/player_metadata_title.dart';
import 'package:spotiflac_android/widgets/playback_seek_slider.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
@@ -259,6 +261,44 @@ void main() {
return frames;
}
testWidgets('Material track titles never overlap during rapid skips', (
tester,
) async {
await pumpNowPlaying(tester);
mediaItems.add(item('first'));
await settlePlayer(tester);
mediaItems.add(item('second'));
await tester.pump();
var sawOutgoingTitle = false;
var visible = 0;
for (var frame = 0; frame < 60; frame++) {
if (frame == 7) mediaItems.add(item('third'));
await tester.pump(const Duration(milliseconds: 16));
final titles = find.descendant(
of: find.byType(MaterialPlayerLayout),
matching: find.byType(PlayerMetadataTitle),
);
expect(titles, findsAtLeast(1));
sawOutgoingTitle |= titles.evaluate().length > 1;
visible = 0;
for (final title in titles.evaluate()) {
var opacity = 1.0;
title.visitAncestorElements((element) {
final widget = element.widget;
if (widget is FadeTransition) opacity *= widget.opacity.value;
if (widget is Opacity) opacity *= widget.opacity;
return true;
});
if (opacity > 0.01) visible++;
}
expect(visible, lessThanOrEqualTo(1), reason: 'frame $frame');
}
expect(sawOutgoingTitle, isTrue);
expect(visible, 1);
await settlePlayer(tester);
expect(tester.takeException(), isNull);
});
testWidgets('Material seek wave follows playback, buffering and pause', (
tester,
) async {