mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
feat(player): scroll overflowing mini player titles and artists
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user