feat(lyrics): animate pronunciation and translation visibility

Add a contextual language menu for available lyric supplements in portrait and landscape. Remember independent visibility choices across tracks and player sessions without changing embedded lyrics or download options.

Fade and collapse supplements over 320 ms while interpolating lyric row heights and keeping the active line aligned. Honor reduced motion and control auto-hide; cover menu availability, persistence, animation, and word timing in widget tests.
This commit is contained in:
zarzet
2026-09-25 10:29:22 +07:00
parent 87321c7efd
commit 3ca881b169
8 changed files with 500 additions and 33 deletions
+8
View File
@@ -6348,6 +6348,14 @@
"description": "Tab label for the lyrics view"
},
"nowPlayingNoLyrics": "No lyrics in this file",
"nowPlayingLyricsLanguageOptions": "Lyrics language options",
"@nowPlayingLyricsLanguageOptions": {
"description": "Tooltip for the player menu that shows or hides existing lyric pronunciation and translation"
},
"nowPlayingHidePronunciation": "Hide Pronunciation",
"nowPlayingShowPronunciation": "Show Pronunciation",
"nowPlayingHideTranslation": "Hide Translation",
"nowPlayingShowTranslation": "Show Translation",
"@nowPlayingNoLyrics": {
"description": "Empty state when the playing file has no embedded lyrics"
},
+5
View File
@@ -5688,6 +5688,11 @@
"setupStorageGranted": "Izin Penyimpanan Diberikan!",
"downloadAllowLocalNetwork": "Allow Local Network Access",
"nowPlayingNoLyrics": "No lyrics in this file",
"nowPlayingLyricsLanguageOptions": "Opsi bahasa lirik",
"nowPlayingHidePronunciation": "Sembunyikan Pelafalan",
"nowPlayingShowPronunciation": "Tampilkan Pelafalan",
"nowPlayingHideTranslation": "Sembunyikan Terjemahan",
"nowPlayingShowTranslation": "Tampilkan Terjemahan",
"selectionDeleteTracksCount": "Delete {count} {count, plural, =1{track} other{tracks}}",
"@selectionDeleteTracksCount": {
"description": "Button label for deleting selected tracks",
+10
View File
@@ -126,6 +126,8 @@ class AppSettings {
final bool saveDownloadHistory;
final String playerMode;
final bool playerShowPronunciation;
final bool playerShowTranslation;
const AppSettings({
this.defaultService = '',
@@ -202,6 +204,8 @@ class AppSettings {
this.allowQualityVariants = false,
this.saveDownloadHistory = true,
this.playerMode = 'external',
this.playerShowPronunciation = true,
this.playerShowTranslation = true,
});
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -291,6 +295,8 @@ class AppSettings {
bool? allowQualityVariants,
bool? saveDownloadHistory,
String? playerMode,
bool? playerShowPronunciation,
bool? playerShowTranslation,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -395,6 +401,10 @@ class AppSettings {
allowQualityVariants: allowQualityVariants ?? this.allowQualityVariants,
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
playerMode: playerMode ?? this.playerMode,
playerShowPronunciation:
playerShowPronunciation ?? this.playerShowPronunciation,
playerShowTranslation:
playerShowTranslation ?? this.playerShowTranslation,
);
}
+4
View File
@@ -101,6 +101,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
allowQualityVariants: json['allowQualityVariants'] as bool? ?? false,
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
playerMode: json['playerMode'] as String? ?? 'external',
playerShowPronunciation: json['playerShowPronunciation'] as bool? ?? true,
playerShowTranslation: json['playerShowTranslation'] as bool? ?? true,
);
Map<String, dynamic> _$AppSettingsToJson(
@@ -181,4 +183,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'allowQualityVariants': instance.allowQualityVariants,
'saveDownloadHistory': instance.saveDownloadHistory,
'playerMode': instance.playerMode,
'playerShowPronunciation': instance.playerShowPronunciation,
'playerShowTranslation': instance.playerShowTranslation,
};
+10
View File
@@ -912,6 +912,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(playerMode: normalized);
_saveSettings();
}
void setPlayerShowPronunciation(bool visible) {
state = state.copyWith(playerShowPronunciation: visible);
_saveSettings();
}
void setPlayerShowTranslation(bool visible) {
state = state.copyWith(playerShowTranslation: visible);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+250 -33
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/services/library_database.dart';
@@ -329,6 +330,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
double? _motionAspectRatio;
String? _failedMotionSource;
bool _lyricsControlsHidden = false;
bool _lyricsOptionsOpen = false;
double _lyricsScrollDistance = 0;
Timer? _lyricsIdleTimer;
final _lyricsPointers = <int, Offset>{};
@@ -1031,6 +1033,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
void _scheduleLyricsControlsHide() {
_lyricsIdleTimer?.cancel();
if (!_canAutoHideLyricsControls ||
_lyricsOptionsOpen ||
_lyricsControlsHidden ||
_lyricsPointers.isNotEmpty) {
return;
@@ -1413,6 +1416,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
artwork: stage(artworkOnly: true),
header: _trackHeader(mediaItem, colorScheme, compact: true),
lyrics: _lyricsSection(colorScheme, isActive: showLyrics),
lyricsOptions: showLyrics
? _lyricsOptionsButton(colorScheme)
: null,
queue: MornyePlayerQueue(
colorScheme: colorScheme,
onShuffleLibrary: () => _shuffleLibrary(controller),
@@ -1659,10 +1665,34 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
);
}
if (_lyrics.synced) {
return _SyncedLyricsView(
lyrics: _lyrics,
colorScheme: colorScheme,
isActive: isActive,
final visibility = ref.watch(
settingsProvider.select(
(settings) => (
settings.playerShowPronunciation,
settings.playerShowTranslation,
),
),
);
final options = isActive && !_landscape
? _lyricsOptionsButton(colorScheme)
: null;
return Stack(
fit: StackFit.expand,
children: [
_SyncedLyricsView(
lyrics: _lyrics,
colorScheme: colorScheme,
isActive: isActive,
showPronunciation: visibility.$1,
showTranslation: visibility.$2,
),
if (options != null)
Positioned(
left: 24,
bottom: 8,
child: _autoHidingLyricsControls(options),
),
],
);
}
return SingleChildScrollView(
@@ -1678,6 +1708,122 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
);
}
Widget? _lyricsOptionsButton(ColorScheme colorScheme) {
if (_loadingMeta || !_lyrics.synced) return null;
final pronunciation = _lyrics.lines.any(
(line) => line.romanization?.trim().isNotEmpty == true,
);
final translation = _lyrics.lines.any(
(line) => line.translation?.trim().isNotEmpty == true,
);
if (!pronunciation && !translation) return null;
return Builder(
builder: (buttonContext) => IconButton.filledTonal(
key: const ValueKey('lyrics-language-options'),
tooltip: context.l10n.nowPlayingLyricsLanguageOptions,
icon: const Icon(Icons.translate_rounded),
style: IconButton.styleFrom(
foregroundColor: _lyricsOptionsOpen
? colorScheme.surface
: colorScheme.onSurface,
backgroundColor: colorScheme.onSurface.withValues(
alpha: _lyricsOptionsOpen ? 0.85 : 0.16,
),
),
onPressed: () => _showLyricsOptions(
buttonContext,
pronunciation: pronunciation,
translation: translation,
),
),
);
}
Future<void> _showLyricsOptions(
BuildContext buttonContext, {
required bool pronunciation,
required bool translation,
}) async {
final settings = ref.read(settingsProvider);
final l10n = context.l10n;
final actions = <(String, String, IconData)>[
if (pronunciation)
(
'pronunciation',
settings.playerShowPronunciation
? l10n.nowPlayingHidePronunciation
: l10n.nowPlayingShowPronunciation,
settings.playerShowPronunciation
? Icons.voice_over_off_outlined
: Icons.record_voice_over_outlined,
),
if (translation)
(
'translation',
settings.playerShowTranslation
? l10n.nowPlayingHideTranslation
: l10n.nowPlayingShowTranslation,
Icons.translate_rounded,
),
];
_lyricsIdleTimer?.cancel();
setState(() => _lyricsOptionsOpen = true);
try {
final anchor = mornyeMenuAnchor(buttonContext);
final action = context.isMornye
? await showMornyeContextMenu<String>(
context: buttonContext,
anchor: anchor,
preferAbove: true,
builder: (menuContext) => MornyeContextMenu(
dense: true,
groups: [
[
for (final (value, label, icon) in actions)
MornyeMenuAction(
icon: icon,
label: label,
onPressed: () => Navigator.of(menuContext).pop(value),
),
],
],
),
)
: await showMenu<String>(
context: buttonContext,
position: RelativeRect.fromRect(
anchor ?? Rect.zero,
Offset.zero & MediaQuery.sizeOf(context),
),
items: [
for (final (value, label, icon) in actions)
PopupMenuItem(
value: value,
child: Row(
children: [
Icon(icon),
const SizedBox(width: 12),
Flexible(child: Text(label)),
],
),
),
],
);
if (!mounted) return;
final notifier = ref.read(settingsProvider.notifier);
if (action == 'pronunciation') {
notifier.setPlayerShowPronunciation(!settings.playerShowPronunciation);
} else if (action == 'translation') {
notifier.setPlayerShowTranslation(!settings.playerShowTranslation);
}
} finally {
if (mounted) {
setState(() => _lyricsOptionsOpen = false);
_scheduleLyricsControlsHide();
}
}
}
Future<void> _openExternally(String source) async {
if (source.isEmpty) return;
try {
@@ -2524,11 +2670,15 @@ class _SyncedLyricsView extends ConsumerStatefulWidget {
final ParsedLyrics lyrics;
final ColorScheme colorScheme;
final bool isActive;
final bool showPronunciation;
final bool showTranslation;
const _SyncedLyricsView({
required this.lyrics,
required this.colorScheme,
required this.isActive,
required this.showPronunciation,
required this.showTranslation,
});
@override
@@ -2551,8 +2701,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
bool _userScrolling = false;
static const double _estimatedLyricExtent = 64;
List<double>? _lineExtents;
List<(double, double, double)> _lineMeasurements = [];
Object? _lineLayoutKey;
double? _viewportHeight;
Offset? _layoutVisibility;
@override
void initState() {
@@ -2716,22 +2868,31 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
textScaler: scaler,
locale: locale,
);
final extents = <double>[];
final measurements = <(double, double, double)>[];
for (final line in widget.lyrics.lines) {
painter.text = TextSpan(
text: line.text.trim().isEmpty ? '\u00b7\u00b7\u00b7' : line.text,
style: style,
);
painter.layout(maxWidth: width);
var height = painter.height + 32;
for (final (text, style, _) in _lyricSupplements(context, line)) {
final height = painter.height + 32;
var pronunciationHeight = 0.0;
var translationHeight = 0.0;
for (final (text, style, _, translation) in _lyricSupplements(
context,
line,
)) {
painter.text = TextSpan(text: text, style: style);
painter.layout(maxWidth: width);
height += 6 + painter.height;
if (translation) {
translationHeight = 6 + painter.height;
} else {
pronunciationHeight = 6 + painter.height;
}
}
extents.add(height);
measurements.add((height, pronunciationHeight, translationHeight));
}
_lineExtents = extents;
_lineMeasurements = measurements;
painter.dispose();
}
@@ -2774,7 +2935,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
lineContext,
alignment: 0.5,
alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
duration: const Duration(milliseconds: 380),
duration: immediate
? Duration.zero
: const Duration(milliseconds: 380),
curve: Curves.easeOutCubic,
);
return;
@@ -2809,7 +2972,21 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) => TweenAnimationBuilder<Offset>(
tween: Tween(
end: Offset(
widget.showPronunciation ? 1 : 0,
widget.showTranslation ? 1 : 0,
),
),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 320),
curve: Curves.easeInOutCubic,
builder: (context, visibility, _) => _buildLyrics(context, visibility),
);
Widget _buildLyrics(BuildContext context, Offset visibility) {
final lines = widget.lyrics.lines;
final active = _active;
final mornye = context.isMornye;
@@ -2842,14 +3019,25 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
_measureMornyeLines(
(constraints.maxWidth - 48).clamp(0, double.infinity),
);
if (_viewportHeight != constraints.maxHeight) {
_viewportHeight = constraints.maxHeight;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(_maybeAutoScroll(_active, immediate: true));
}
});
}
// 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,
];
}
if (_viewportHeight != constraints.maxHeight ||
_layoutVisibility != visibility) {
_viewportHeight = constraints.maxHeight;
_layoutVisibility = visibility;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(_maybeAutoScroll(_active, immediate: true));
}
});
}
final centerPadding = syncedLyricsCenterPadding(
viewportDimension: constraints.maxHeight,
@@ -2896,6 +3084,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
colorScheme: widget.colorScheme,
animate: widget.isActive,
initialPosition: _activeTransitionPosition,
supplementVisibility: visibility,
);
} else {
content = Text(
@@ -2914,7 +3103,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
color: color,
),
);
content = _withLyricSupplements(context, line, content, color);
content = _withLyricSupplements(
context,
line,
content,
color,
visibility: visibility,
);
}
content = AnimatedSwitcher(
duration: const Duration(milliseconds: 320),
@@ -2984,7 +3179,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
}
}
Iterable<(String, TextStyle, List<LyricWord>)> _lyricSupplements(
Iterable<(String, TextStyle, List<LyricWord>, bool)> _lyricSupplements(
BuildContext context,
LyricLine line,
) sync* {
@@ -3004,6 +3199,7 @@ Iterable<(String, TextStyle, List<LyricWord>)> _lyricSupplements(
fontWeight: FontWeight.w500,
),
translation ? const <LyricWord>[] : line.romanizationWords,
translation,
);
}
}
@@ -3013,6 +3209,7 @@ Widget _withLyricSupplements(
LyricLine line,
Widget primary,
Color color, {
required Offset visibility,
Widget Function(String, List<LyricWord>, TextStyle)? timedText,
}) {
if (line.romanization == null && line.translation == null) return primary;
@@ -3022,19 +3219,36 @@ Widget _withLyricSupplements(
: CrossAxisAlignment.center,
children: [
primary,
for (final (text, style, words) in _lyricSupplements(context, line)) ...[
const SizedBox(height: 6),
if (words.isNotEmpty && timedText != null)
timedText(text, words, style)
else
Text(
text,
textAlign: context.isMornye ? TextAlign.start : TextAlign.center,
style: style.copyWith(
color: color.withValues(alpha: color.a * 0.8),
for (final (text, style, words, translation) in _lyricSupplements(
context,
line,
))
if ((translation ? visibility.dy : visibility.dx) > 0)
ClipRect(
child: Align(
alignment: context.isMornye
? Alignment.topLeft
: Alignment.topCenter,
heightFactor: translation ? visibility.dy : visibility.dx,
child: Opacity(
opacity: translation ? visibility.dy : visibility.dx,
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: words.isNotEmpty && timedText != null
? timedText(text, words, style)
: Text(
text,
textAlign: context.isMornye
? TextAlign.start
: TextAlign.center,
style: style.copyWith(
color: color.withValues(alpha: color.a * 0.8),
),
),
),
),
),
),
],
],
);
}
@@ -3044,12 +3258,14 @@ class _WordHighlightedLyricLine extends ConsumerStatefulWidget {
final ColorScheme colorScheme;
final bool animate;
final Duration initialPosition;
final Offset supplementVisibility;
const _WordHighlightedLyricLine({
required this.line,
required this.colorScheme,
required this.animate,
required this.initialPosition,
required this.supplementVisibility,
});
@override
@@ -3217,6 +3433,7 @@ class _WordHighlightedLyricLineState
primary,
widget.colorScheme.onSurface,
timedText: _buildTimedText,
visibility: widget.supplementVisibility,
);
}
+5
View File
@@ -17,6 +17,7 @@ class MornyeLandscapePlayer extends StatefulWidget {
required this.volume,
required this.page,
required this.onPageChanged,
this.lyricsOptions,
});
final Widget artwork;
@@ -27,6 +28,7 @@ class MornyeLandscapePlayer extends StatefulWidget {
final Widget volume;
final int page;
final ValueChanged<int> onPageChanged;
final Widget? lyricsOptions;
@override
State<MornyeLandscapePlayer> createState() => _MornyeLandscapePlayerState();
@@ -193,6 +195,9 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
widget.page == 1 ? 0 : 1,
),
),
if (widget.page == 1 &&
widget.lyricsOptions != null)
widget.lyricsOptions!,
IconButton(
tooltip:
context.l10n.nowPlayingUpNext,
@@ -39,6 +39,9 @@ import 'package:spotiflac_android/widgets/mini_player.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const backendChannel = MethodChannel('com.zarz.spotiflac/backend');
const secureStorageChannel = MethodChannel(
'plugins.it_nomads.com/flutter_secure_storage',
);
late StreamController<MediaItem?> mediaItems;
late List<double> volumeWrites;
late Map<String, dynamic> metadataOverrides;
@@ -50,6 +53,8 @@ void main() {
volumeWrites = [];
metadataOverrides = {};
metadataReads = [];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(secureStorageChannel, (_) async => null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, (call) async {
if (call.method != 'readFileMetadata') {
@@ -77,6 +82,8 @@ void main() {
await mediaItems.close();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(backendChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(secureStorageChannel, null);
});
MediaItem item(String id) => MediaItem(
@@ -1634,6 +1641,98 @@ void main() {
},
);
for (final supplement in ['none', 'pronunciation', 'translation']) {
testWidgets('language menu only offers available $supplement', (
tester,
) async {
metadataOverrides['lyrics'] =
'${supplement == 'pronunciation' ? '[x-romaji:1009:${base64.encode(utf8.encode('Pronunciation'))}]\n' : ''}'
'${supplement == 'translation' ? '[x-translation:1009:${base64.encode(utf8.encode('Translation'))}]\n' : ''}'
'[00:01.009]Original';
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(390, 844),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final options = find.byKey(const ValueKey('lyrics-language-options'));
if (supplement == 'none') {
expect(options, findsNothing);
} else {
await tester.tap(options);
await tester.pumpAndSettle();
expect(
find.text('Hide Pronunciation'),
supplement == 'pronunciation' ? findsOneWidget : findsNothing,
);
expect(
find.text('Hide Translation'),
supplement == 'translation' ? findsOneWidget : findsNothing,
);
}
expect(tester.takeException(), isNull);
});
}
for (final landscape in [false, true]) {
testWidgets(
'language menu respects reduced motion and idle controls (landscape: $landscape)',
(tester) async {
metadataOverrides['lyrics'] =
'[x-romaji:1009:${base64.encode(utf8.encode('Pronunciation'))}]\n'
'[00:01.009]Original';
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: landscape ? const Size(844, 390) : const Size(390, 844),
wrapPlayer: (player) => Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: player,
),
),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final options = find.byKey(const ValueKey('lyrics-language-options'));
await tester.tap(options);
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 4));
expect(find.text('Hide Pronunciation').hitTestable(), findsOneWidget);
await tester.tap(find.text('Hide Pronunciation'));
await tester.pump();
await tester.pump();
expect(find.text('Pronunciation'), findsNothing);
expect(options.hitTestable(), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(options.hitTestable(), findsNothing);
if (landscape) {
await tester.tap(
find.byKey(const ValueKey('landscape-actions-reveal')),
);
} else {
final bounds = tester.getRect(find.byType(ListView));
await tester.tapAt(Offset(bounds.right - 6, bounds.top + 10));
}
await tester.pumpAndSettle();
await tester.tap(options);
await tester.pumpAndSettle();
await tester.tap(find.text('Show Pronunciation'));
await tester.pump();
await tester.pump();
expect(find.text('Pronunciation'), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
}
for (final mornye in [false, true]) {
testWidgets('player shows original, romanization and English ($mornye)', (
tester,
@@ -1669,6 +1768,94 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets(
'lyric language toggles fade, resize and remember choices ($mornye)',
(tester) async {
metadataOverrides['lyrics'] =
'[x-romaji:1009:${base64.encode(utf8.encode('Romanized text'))}]\n'
'[x-translation:1009:${base64.encode(utf8.encode('English text'))}]\n'
'[00:01.009]Original text\n[00:10.000]Next line';
Future<void> openPlayer(String id) async {
await pumpNowPlaying(
tester,
theme: mornye ? MornyeTheme.build(Brightness.dark) : null,
size: const Size(390, 844),
playback: PlaybackState(
processingState: AudioProcessingState.ready,
updatePosition: const Duration(milliseconds: 1500),
),
);
mediaItems.add(item(id));
await tester.pumpAndSettle();
if (mornye) {
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
} else {
await tester.drag(find.byType(PageView), const Offset(-350, 0));
}
await tester.pumpAndSettle();
}
final options = find.byKey(const ValueKey('lyrics-language-options'));
Future<void> choose(String label) async {
await tester.tap(options);
await tester.pumpAndSettle();
await tester.tap(find.text(label));
await tester.pump();
}
double gap() =>
tester.getTopLeft(find.text('Next line')).dy -
tester.getTopLeft(find.text('Original text')).dy;
double pronunciationOpacity() => tester
.widget<Opacity>(
find
.ancestor(
of: find.text('Romanized text'),
matching: find.byType(Opacity),
)
.first,
)
.opacity;
await openPlayer('first');
final expandedGap = gap();
await choose('Hide Pronunciation');
await tester.pump(const Duration(milliseconds: 100));
expect(pronunciationOpacity(), inExclusiveRange(0, 1));
final intermediateGap = gap();
expect(intermediateGap, lessThan(expandedGap));
await tester.pumpAndSettle();
expect(find.text('Romanized text'), findsNothing);
expect(find.text('English text'), findsOneWidget);
expect(gap(), lessThan(intermediateGap));
await choose('Show Pronunciation');
await tester.pump(const Duration(milliseconds: 100));
expect(pronunciationOpacity(), inExclusiveRange(0, 1));
await tester.pumpAndSettle();
expect(gap(), closeTo(expandedGap, 1));
await choose('Hide Translation');
await tester.pumpAndSettle();
expect(find.text('English text'), findsNothing);
expect(find.text('Romanized text'), findsOneWidget);
await choose('Hide Pronunciation');
await tester.pumpAndSettle();
expect(find.text('Original text'), findsOneWidget);
expect(find.text('Romanized text'), findsNothing);
expect(options.hitTestable(), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await openPlayer('second');
expect(find.text('English text'), findsNothing);
expect(find.text('Romanized text'), findsNothing);
await choose('Show Translation');
await tester.pumpAndSettle();
expect(find.text('English text'), findsOneWidget);
expect(find.text('Romanized text'), findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'original and romanization follow word timing and pauses ($mornye)',
(tester) async {
@@ -1750,6 +1937,27 @@ void main() {
expect(await pixelsAt(2900, text), orderedEquals(lastEnded));
expect(await pixelsAt(1100, text), orderedEquals(singingFirst));
}
final originalPixels = await pixelsAt(1100, 'First second');
final options = find.byKey(const ValueKey('lyrics-language-options'));
if (mornye && options.hitTestable().evaluate().isEmpty) {
final bounds = tester.getRect(find.byType(ListView));
await tester.tapAt(Offset(bounds.right - 6, bounds.top + 10));
await tester.pumpAndSettle();
}
await tester.tap(options);
await tester.pumpAndSettle();
await tester.tap(find.text('Hide Pronunciation'));
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('Firsu secondu'), findsNothing);
expect(
await pixelsAt(1100, 'First second'),
orderedEquals(originalPixels),
);
await tester.tap(options);
await tester.pumpAndSettle();
await tester.tap(find.text('Show Pronunciation'));
await tester.pumpAndSettle();
await pixelsAt(2150, 'Firsu secondu');
expect(tester.takeException(), isNull);
},
);