From d56396f90e70a834c1843962cedda118a9674f00 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:44:48 +0700 Subject: [PATCH] feat(player): scroll overflowing mini player titles and artists --- lib/widgets/mini_player.dart | 63 +++++---- lib/widgets/overflow_marquee.dart | 146 +++++++++++++++++++ test/overflow_marquee_test.dart | 228 ++++++++++++++++++++++++++++++ 3 files changed, 409 insertions(+), 28 deletions(-) create mode 100644 lib/widgets/overflow_marquee.dart create mode 100644 test/overflow_marquee_test.dart diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index eb7160f3..1907a33a 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -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 { 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, + ), ), ], ), diff --git a/lib/widgets/overflow_marquee.dart b/lib/widgets/overflow_marquee.dart new file mode 100644 index 00000000..e990ba0c --- /dev/null +++ b/lib/widgets/overflow_marquee.dart @@ -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 createState() => _OverflowMarqueeState(); +} + +class _OverflowMarqueeState extends State + 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 _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( + 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, + ), + ), + ); + } +} diff --git a/test/overflow_marquee_test.dart b/test/overflow_marquee_test.dart new file mode 100644 index 00000000..f5dd1b1f --- /dev/null +++ b/test/overflow_marquee_test.dart @@ -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 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(find.byType(SingleChildScrollView)) + .controller!; + + Future 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(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( + 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()); + }, + ); + } + } +}