feat(player): scroll overflowing mini player titles and artists

This commit is contained in:
zarzet
2026-09-24 00:44:48 +07:00
parent 1e3414b3d0
commit d56396f90e
3 changed files with 409 additions and 28 deletions
+35 -28
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/overflow_marquee.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
@@ -126,35 +127,41 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ExplicitTrackTitle(
title: mediaItem.title,
explicit:
parseExplicitFlag(
mediaItem.extras?['explicit'],
) ==
true,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
OverflowMarquee(
resetKey: (mediaItem.id, mediaItem.title),
child: ExplicitTrackTitle(
title: mediaItem.title,
explicit:
parseExplicitFlag(
mediaItem.extras?['explicit'],
) ==
true,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
mediaItem.artist ?? '',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: mornye
? Color.lerp(
colorScheme.onSurfaceVariant,
colorScheme.onSurface,
colorScheme.brightness ==
Brightness.dark
? 0.7
: 0.6,
)
: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
OverflowMarquee(
resetKey: (mediaItem.id, mediaItem.artist),
child: Text(
mediaItem.artist ?? '',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: mornye
? Color.lerp(
colorScheme.onSurfaceVariant,
colorScheme.onSurface,
colorScheme.brightness ==
Brightness.dark
? 0.7
: 0.6,
)
: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
+146
View File
@@ -0,0 +1,146 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// Reveals overflowing content without making the mini player's text draggable.
class OverflowMarquee extends StatefulWidget {
const OverflowMarquee({
super.key,
required this.resetKey,
required this.child,
});
final Object resetKey;
final Widget child;
@override
State<OverflowMarquee> createState() => _OverflowMarqueeState();
}
class _OverflowMarqueeState extends State<OverflowMarquee>
with WidgetsBindingObserver {
final _scroll = ScrollController();
Timer? _pause;
int _generation = 0;
bool _restartQueued = false;
bool _motionEnabled = false;
bool _appActive = true;
double? _viewport;
double? _extent;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
final lifecycle = WidgetsBinding.instance.lifecycleState;
_appActive = lifecycle == null || lifecycle == AppLifecycleState.resumed;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_motionEnabled =
!MediaQuery.disableAnimationsOf(context) &&
TickerMode.valuesOf(context).enabled;
_queueRestart();
}
@override
void didUpdateWidget(OverflowMarquee oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.resetKey != oldWidget.resetKey) {
_queueRestart();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_appActive = state == AppLifecycleState.resumed;
_queueRestart();
}
void _queueRestart() {
_pause?.cancel();
_generation++;
if (_restartQueued) return;
_restartQueued = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_restartQueued = false;
if (!mounted || !_scroll.hasClients) return;
// Also cancels an in-flight animation after a track/width change.
_scroll.jumpTo(0);
if (_motionEnabled &&
_appActive &&
_scroll.position.maxScrollExtent > 0) {
_scheduleLeg(towardEnd: true, generation: _generation);
}
});
WidgetsBinding.instance.ensureVisualUpdate();
}
bool _canScroll(int generation) =>
mounted &&
generation == _generation &&
_motionEnabled &&
_appActive &&
_scroll.hasClients;
void _scheduleLeg({required bool towardEnd, required int generation}) {
_pause = Timer(
Duration(milliseconds: towardEnd ? 1500 : 1200),
() => _scrollLeg(towardEnd: towardEnd, generation: generation),
);
}
Future<void> _scrollLeg({
required bool towardEnd,
required int generation,
}) async {
if (!_canScroll(generation)) return;
final target = towardEnd ? _scroll.position.maxScrollExtent : 0.0;
final distance = (target - _scroll.offset).abs();
if (distance < 0.5) return;
await _scroll.animateTo(
target,
duration: Duration(milliseconds: (distance / 28 * 1000).round()),
curve: Curves.linear,
);
if (_canScroll(generation)) {
_scheduleLeg(towardEnd: !towardEnd, generation: generation);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_pause?.cancel();
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_motionEnabled) return widget.child;
return NotificationListener<ScrollMetricsNotification>(
onNotification: (notification) {
final metrics = notification.metrics;
if (_viewport != metrics.viewportDimension ||
_extent != metrics.maxScrollExtent) {
_viewport = metrics.viewportDimension;
_extent = metrics.maxScrollExtent;
_queueRestart();
}
return false;
},
child: RepaintBoundary(
child: SingleChildScrollView(
controller: _scroll,
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: widget.child,
),
),
);
}
}
+228
View File
@@ -0,0 +1,228 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/mini_player.dart';
import 'package:spotiflac_android/widgets/overflow_marquee.dart';
void main() {
const longTitle = 'A long song title with enough words to leave the viewport';
Future<void> pumpTitle(
WidgetTester tester, {
String title = longTitle,
double width = 160,
bool explicit = false,
bool disableAnimations = false,
bool tickersEnabled = true,
TextDirection direction = TextDirection.ltr,
}) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: MediaQuery(
data: MediaQueryData(disableAnimations: disableAnimations),
child: Directionality(
textDirection: direction,
child: TickerMode(
enabled: tickersEnabled,
child: Center(
child: SizedBox(
width: width,
child: OverflowMarquee(
resetKey: title,
child: ExplicitTrackTitle(
title: title,
explicit: explicit,
style: const TextStyle(fontSize: 16),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
),
),
),
);
await tester.pump();
}
ScrollController controller(WidgetTester tester) => tester
.widget<SingleChildScrollView>(find.byType(SingleChildScrollView))
.controller!;
Future<void> startScrolling(WidgetTester tester) async {
await tester.pump(const Duration(milliseconds: 1500));
await tester.pump(const Duration(seconds: 1));
}
testWidgets('short titles stay still and schedule no animation', (
tester,
) async {
await pumpTitle(tester, title: 'RH YNO', explicit: true);
await tester.pump(const Duration(seconds: 10));
expect(controller(tester).position.maxScrollExtent, 0);
expect(controller(tester).offset, 0);
expect(find.byType(ExplicitBadge), findsOneWidget);
expect(tester.binding.transientCallbackCount, 0);
});
testWidgets('long titles pause, reveal the end, and return smoothly', (
tester,
) async {
await pumpTitle(tester, explicit: true);
final scroll = controller(tester);
await tester.pump(const Duration(seconds: 1));
expect(scroll.offset, 0);
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(seconds: 1));
expect(scroll.offset, closeTo(28, 1));
await tester.pump(const Duration(minutes: 1));
final end = scroll.position.maxScrollExtent;
expect(scroll.offset, end);
expect(find.byType(ExplicitBadge), findsOneWidget);
await tester.pump(const Duration(seconds: 1));
expect(scroll.offset, end);
await tester.pump(const Duration(milliseconds: 200));
await tester.pump(const Duration(seconds: 1));
expect(scroll.offset, closeTo(end - 28, 1));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('track changes and compact widths restart at the beginning', (
tester,
) async {
await pumpTitle(tester);
await startScrolling(tester);
expect(controller(tester).offset, greaterThan(0));
await pumpTitle(tester, width: 100);
expect(controller(tester).offset, 0);
await startScrolling(tester);
expect(controller(tester).offset, greaterThan(0));
await pumpTitle(tester, title: 'Next', width: 100);
expect(controller(tester).offset, 0);
await tester.pump(const Duration(seconds: 10));
expect(controller(tester).offset, 0);
expect(tester.binding.transientCallbackCount, 0);
});
testWidgets('reduced motion and hidden routes stop the marquee', (
tester,
) async {
await pumpTitle(tester);
await startScrolling(tester);
await pumpTitle(tester, disableAnimations: true);
expect(find.byType(SingleChildScrollView), findsNothing);
expect(
tester
.widget<ExplicitTrackTitle>(find.byType(ExplicitTrackTitle))
.overflow,
TextOverflow.ellipsis,
);
await tester.pump(const Duration(seconds: 10));
expect(tester.binding.transientCallbackCount, 0);
await pumpTitle(tester);
await startScrolling(tester);
expect(controller(tester).offset, greaterThan(0));
await pumpTitle(tester, tickersEnabled: false);
await tester.pump(const Duration(seconds: 10));
expect(tester.binding.transientCallbackCount, 0);
});
testWidgets('backgrounding stops motion and resume waits before scrolling', (
tester,
) async {
await pumpTitle(tester);
await startScrolling(tester);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
await tester.pump(const Duration(seconds: 10));
expect(controller(tester).offset, 0);
expect(tester.binding.transientCallbackCount, 0);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(controller(tester).offset, 0);
await startScrolling(tester);
expect(controller(tester).offset, greaterThan(0));
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('RTL titles scroll from the reading direction', (tester) async {
await pumpTitle(tester, direction: TextDirection.rtl);
expect(controller(tester).position.axisDirection, AxisDirection.left);
await startScrolling(tester);
expect(controller(tester).offset, greaterThan(0));
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
});
for (final mornye in [false, true]) {
for (final compact in [false, true]) {
testWidgets(
'mini player scrolls title and artist (Mornye: $mornye, compact: $compact)',
(tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentMediaItemProvider.overrideWith(
(ref) => Stream.value(
const MediaItem(
id: 'long-track',
title: longTitle,
artist: 'First artist, second artist and another artist',
extras: {'explicit': true},
),
),
),
playbackStateProvider.overrideWith(
(ref) => const Stream.empty(),
),
lowEndDeviceProvider.overrideWithValue(true),
backdropBlurEnabledProvider.overrideWithValue(false),
],
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.light)
: ThemeData(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Center(
child: SizedBox(
width: 320,
child: MiniPlayer(compact: compact),
),
),
),
),
),
);
await tester.pump();
await tester.pump();
await startScrolling(tester);
final scrollViews = tester.widgetList<SingleChildScrollView>(
find.descendant(
of: find.byType(MiniPlayer),
matching: find.byType(SingleChildScrollView),
),
);
expect(scrollViews, hasLength(2));
for (final view in scrollViews) {
expect(view.controller!.offset, greaterThan(0));
}
expect(find.byType(ExplicitBadge), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
}
}