mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
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:
1 parent
87321c7efd
commit
3ca881b169
8 files changed
+500
-33
No files matched your search
@@ -6348,6 +6348,14 @@
|
|||||||
"description": "Tab label for the lyrics view"
|
"description": "Tab label for the lyrics view"
|
||||||
},
|
},
|
||||||
"nowPlayingNoLyrics": "No lyrics in this file",
|
"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": {
|
"@nowPlayingNoLyrics": {
|
||||||
"description": "Empty state when the playing file has no embedded lyrics"
|
"description": "Empty state when the playing file has no embedded lyrics"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5688,6 +5688,11 @@
|
|||||||
"setupStorageGranted": "Izin Penyimpanan Diberikan!",
|
"setupStorageGranted": "Izin Penyimpanan Diberikan!",
|
||||||
"downloadAllowLocalNetwork": "Allow Local Network Access",
|
"downloadAllowLocalNetwork": "Allow Local Network Access",
|
||||||
"nowPlayingNoLyrics": "No lyrics in this file",
|
"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": "Delete {count} {count, plural, =1{track} other{tracks}}",
|
||||||
"@selectionDeleteTracksCount": {
|
"@selectionDeleteTracksCount": {
|
||||||
"description": "Button label for deleting selected tracks",
|
"description": "Button label for deleting selected tracks",
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ class AppSettings {
|
|||||||
final bool saveDownloadHistory;
|
final bool saveDownloadHistory;
|
||||||
|
|
||||||
final String playerMode;
|
final String playerMode;
|
||||||
|
final bool playerShowPronunciation;
|
||||||
|
final bool playerShowTranslation;
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = '',
|
this.defaultService = '',
|
||||||
@@ -202,6 +204,8 @@ class AppSettings {
|
|||||||
this.allowQualityVariants = false,
|
this.allowQualityVariants = false,
|
||||||
this.saveDownloadHistory = true,
|
this.saveDownloadHistory = true,
|
||||||
this.playerMode = 'external',
|
this.playerMode = 'external',
|
||||||
|
this.playerShowPronunciation = true,
|
||||||
|
this.playerShowTranslation = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -291,6 +295,8 @@ class AppSettings {
|
|||||||
bool? allowQualityVariants,
|
bool? allowQualityVariants,
|
||||||
bool? saveDownloadHistory,
|
bool? saveDownloadHistory,
|
||||||
String? playerMode,
|
String? playerMode,
|
||||||
|
bool? playerShowPronunciation,
|
||||||
|
bool? playerShowTranslation,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -395,6 +401,10 @@ class AppSettings {
|
|||||||
allowQualityVariants: allowQualityVariants ?? this.allowQualityVariants,
|
allowQualityVariants: allowQualityVariants ?? this.allowQualityVariants,
|
||||||
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
|
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
|
||||||
playerMode: playerMode ?? this.playerMode,
|
playerMode: playerMode ?? this.playerMode,
|
||||||
|
playerShowPronunciation:
|
||||||
|
playerShowPronunciation ?? this.playerShowPronunciation,
|
||||||
|
playerShowTranslation:
|
||||||
|
playerShowTranslation ?? this.playerShowTranslation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
allowQualityVariants: json['allowQualityVariants'] as bool? ?? false,
|
allowQualityVariants: json['allowQualityVariants'] as bool? ?? false,
|
||||||
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
|
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
|
||||||
playerMode: json['playerMode'] as String? ?? 'external',
|
playerMode: json['playerMode'] as String? ?? 'external',
|
||||||
|
playerShowPronunciation: json['playerShowPronunciation'] as bool? ?? true,
|
||||||
|
playerShowTranslation: json['playerShowTranslation'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(
|
Map<String, dynamic> _$AppSettingsToJson(
|
||||||
@@ -181,4 +183,6 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'allowQualityVariants': instance.allowQualityVariants,
|
'allowQualityVariants': instance.allowQualityVariants,
|
||||||
'saveDownloadHistory': instance.saveDownloadHistory,
|
'saveDownloadHistory': instance.saveDownloadHistory,
|
||||||
'playerMode': instance.playerMode,
|
'playerMode': instance.playerMode,
|
||||||
|
'playerShowPronunciation': instance.playerShowPronunciation,
|
||||||
|
'playerShowTranslation': instance.playerShowTranslation,
|
||||||
};
|
};
|
||||||
@@ -912,6 +912,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = state.copyWith(playerMode: normalized);
|
state = state.copyWith(playerMode: normalized);
|
||||||
_saveSettings();
|
_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>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -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/library_collections_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/music_player_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/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/downloaded_album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
@@ -329,6 +330,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
double? _motionAspectRatio;
|
double? _motionAspectRatio;
|
||||||
String? _failedMotionSource;
|
String? _failedMotionSource;
|
||||||
bool _lyricsControlsHidden = false;
|
bool _lyricsControlsHidden = false;
|
||||||
|
bool _lyricsOptionsOpen = false;
|
||||||
double _lyricsScrollDistance = 0;
|
double _lyricsScrollDistance = 0;
|
||||||
Timer? _lyricsIdleTimer;
|
Timer? _lyricsIdleTimer;
|
||||||
final _lyricsPointers = <int, Offset>{};
|
final _lyricsPointers = <int, Offset>{};
|
||||||
@@ -1031,6 +1033,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
void _scheduleLyricsControlsHide() {
|
void _scheduleLyricsControlsHide() {
|
||||||
_lyricsIdleTimer?.cancel();
|
_lyricsIdleTimer?.cancel();
|
||||||
if (!_canAutoHideLyricsControls ||
|
if (!_canAutoHideLyricsControls ||
|
||||||
|
_lyricsOptionsOpen ||
|
||||||
_lyricsControlsHidden ||
|
_lyricsControlsHidden ||
|
||||||
_lyricsPointers.isNotEmpty) {
|
_lyricsPointers.isNotEmpty) {
|
||||||
return;
|
return;
|
||||||
@@ -1413,6 +1416,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
artwork: stage(artworkOnly: true),
|
artwork: stage(artworkOnly: true),
|
||||||
header: _trackHeader(mediaItem, colorScheme, compact: true),
|
header: _trackHeader(mediaItem, colorScheme, compact: true),
|
||||||
lyrics: _lyricsSection(colorScheme, isActive: showLyrics),
|
lyrics: _lyricsSection(colorScheme, isActive: showLyrics),
|
||||||
|
lyricsOptions: showLyrics
|
||||||
|
? _lyricsOptionsButton(colorScheme)
|
||||||
|
: null,
|
||||||
queue: MornyePlayerQueue(
|
queue: MornyePlayerQueue(
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
onShuffleLibrary: () => _shuffleLibrary(controller),
|
onShuffleLibrary: () => _shuffleLibrary(controller),
|
||||||
@@ -1659,10 +1665,34 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_lyrics.synced) {
|
if (_lyrics.synced) {
|
||||||
return _SyncedLyricsView(
|
final visibility = ref.watch(
|
||||||
lyrics: _lyrics,
|
settingsProvider.select(
|
||||||
colorScheme: colorScheme,
|
(settings) => (
|
||||||
isActive: isActive,
|
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(
|
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 {
|
Future<void> _openExternally(String source) async {
|
||||||
if (source.isEmpty) return;
|
if (source.isEmpty) return;
|
||||||
try {
|
try {
|
||||||
@@ -2524,11 +2670,15 @@ class _SyncedLyricsView extends ConsumerStatefulWidget {
|
|||||||
final ParsedLyrics lyrics;
|
final ParsedLyrics lyrics;
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
|
final bool showPronunciation;
|
||||||
|
final bool showTranslation;
|
||||||
|
|
||||||
const _SyncedLyricsView({
|
const _SyncedLyricsView({
|
||||||
required this.lyrics,
|
required this.lyrics,
|
||||||
required this.colorScheme,
|
required this.colorScheme,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
|
required this.showPronunciation,
|
||||||
|
required this.showTranslation,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -2551,8 +2701,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
|||||||
bool _userScrolling = false;
|
bool _userScrolling = false;
|
||||||
static const double _estimatedLyricExtent = 64;
|
static const double _estimatedLyricExtent = 64;
|
||||||
List<double>? _lineExtents;
|
List<double>? _lineExtents;
|
||||||
|
List<(double, double, double)> _lineMeasurements = [];
|
||||||
Object? _lineLayoutKey;
|
Object? _lineLayoutKey;
|
||||||
double? _viewportHeight;
|
double? _viewportHeight;
|
||||||
|
Offset? _layoutVisibility;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -2716,22 +2868,31 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
|||||||
textScaler: scaler,
|
textScaler: scaler,
|
||||||
locale: locale,
|
locale: locale,
|
||||||
);
|
);
|
||||||
final extents = <double>[];
|
final measurements = <(double, double, double)>[];
|
||||||
for (final line in widget.lyrics.lines) {
|
for (final line in widget.lyrics.lines) {
|
||||||
painter.text = TextSpan(
|
painter.text = TextSpan(
|
||||||
text: line.text.trim().isEmpty ? '\u00b7\u00b7\u00b7' : line.text,
|
text: line.text.trim().isEmpty ? '\u00b7\u00b7\u00b7' : line.text,
|
||||||
style: style,
|
style: style,
|
||||||
);
|
);
|
||||||
painter.layout(maxWidth: width);
|
painter.layout(maxWidth: width);
|
||||||
var height = painter.height + 32;
|
final height = painter.height + 32;
|
||||||
for (final (text, style, _) in _lyricSupplements(context, line)) {
|
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.text = TextSpan(text: text, style: style);
|
||||||
painter.layout(maxWidth: width);
|
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();
|
painter.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2774,7 +2935,9 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
|||||||
lineContext,
|
lineContext,
|
||||||
alignment: 0.5,
|
alignment: 0.5,
|
||||||
alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
|
alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
|
||||||
duration: const Duration(milliseconds: 380),
|
duration: immediate
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 380),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -2809,7 +2972,21 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 lines = widget.lyrics.lines;
|
||||||
final active = _active;
|
final active = _active;
|
||||||
final mornye = context.isMornye;
|
final mornye = context.isMornye;
|
||||||
@@ -2842,14 +3019,25 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
|||||||
_measureMornyeLines(
|
_measureMornyeLines(
|
||||||
(constraints.maxWidth - 48).clamp(0, double.infinity),
|
(constraints.maxWidth - 48).clamp(0, double.infinity),
|
||||||
);
|
);
|
||||||
if (_viewportHeight != constraints.maxHeight) {
|
// Reuse text measurements; only interpolate row heights as the
|
||||||
_viewportHeight = constraints.maxHeight;
|
// supplements fade. Scrolling follows the same animation clock.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
_lineExtents = [
|
||||||
if (mounted) {
|
for (final (primary, pronunciation, translation)
|
||||||
unawaited(_maybeAutoScroll(_active, immediate: true));
|
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(
|
final centerPadding = syncedLyricsCenterPadding(
|
||||||
viewportDimension: constraints.maxHeight,
|
viewportDimension: constraints.maxHeight,
|
||||||
@@ -2896,6 +3084,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
|||||||
colorScheme: widget.colorScheme,
|
colorScheme: widget.colorScheme,
|
||||||
animate: widget.isActive,
|
animate: widget.isActive,
|
||||||
initialPosition: _activeTransitionPosition,
|
initialPosition: _activeTransitionPosition,
|
||||||
|
supplementVisibility: visibility,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
content = Text(
|
content = Text(
|
||||||
@@ -2914,7 +3103,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> {
|
|||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
content = _withLyricSupplements(context, line, content, color);
|
content = _withLyricSupplements(
|
||||||
|
context,
|
||||||
|
line,
|
||||||
|
content,
|
||||||
|
color,
|
||||||
|
visibility: visibility,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
content = AnimatedSwitcher(
|
content = AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 320),
|
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,
|
BuildContext context,
|
||||||
LyricLine line,
|
LyricLine line,
|
||||||
) sync* {
|
) sync* {
|
||||||
@@ -3004,6 +3199,7 @@ Iterable<(String, TextStyle, List<LyricWord>)> _lyricSupplements(
|
|||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
translation ? const <LyricWord>[] : line.romanizationWords,
|
translation ? const <LyricWord>[] : line.romanizationWords,
|
||||||
|
translation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3013,6 +3209,7 @@ Widget _withLyricSupplements(
|
|||||||
LyricLine line,
|
LyricLine line,
|
||||||
Widget primary,
|
Widget primary,
|
||||||
Color color, {
|
Color color, {
|
||||||
|
required Offset visibility,
|
||||||
Widget Function(String, List<LyricWord>, TextStyle)? timedText,
|
Widget Function(String, List<LyricWord>, TextStyle)? timedText,
|
||||||
}) {
|
}) {
|
||||||
if (line.romanization == null && line.translation == null) return primary;
|
if (line.romanization == null && line.translation == null) return primary;
|
||||||
@@ -3022,19 +3219,36 @@ Widget _withLyricSupplements(
|
|||||||
: CrossAxisAlignment.center,
|
: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
primary,
|
primary,
|
||||||
for (final (text, style, words) in _lyricSupplements(context, line)) ...[
|
for (final (text, style, words, translation) in _lyricSupplements(
|
||||||
const SizedBox(height: 6),
|
context,
|
||||||
if (words.isNotEmpty && timedText != null)
|
line,
|
||||||
timedText(text, words, style)
|
))
|
||||||
else
|
if ((translation ? visibility.dy : visibility.dx) > 0)
|
||||||
Text(
|
ClipRect(
|
||||||
text,
|
child: Align(
|
||||||
textAlign: context.isMornye ? TextAlign.start : TextAlign.center,
|
alignment: context.isMornye
|
||||||
style: style.copyWith(
|
? Alignment.topLeft
|
||||||
color: color.withValues(alpha: color.a * 0.8),
|
: 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 ColorScheme colorScheme;
|
||||||
final bool animate;
|
final bool animate;
|
||||||
final Duration initialPosition;
|
final Duration initialPosition;
|
||||||
|
final Offset supplementVisibility;
|
||||||
|
|
||||||
const _WordHighlightedLyricLine({
|
const _WordHighlightedLyricLine({
|
||||||
required this.line,
|
required this.line,
|
||||||
required this.colorScheme,
|
required this.colorScheme,
|
||||||
required this.animate,
|
required this.animate,
|
||||||
required this.initialPosition,
|
required this.initialPosition,
|
||||||
|
required this.supplementVisibility,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -3217,6 +3433,7 @@ class _WordHighlightedLyricLineState
|
|||||||
primary,
|
primary,
|
||||||
widget.colorScheme.onSurface,
|
widget.colorScheme.onSurface,
|
||||||
timedText: _buildTimedText,
|
timedText: _buildTimedText,
|
||||||
|
visibility: widget.supplementVisibility,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class MornyeLandscapePlayer extends StatefulWidget {
|
|||||||
required this.volume,
|
required this.volume,
|
||||||
required this.page,
|
required this.page,
|
||||||
required this.onPageChanged,
|
required this.onPageChanged,
|
||||||
|
this.lyricsOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget artwork;
|
final Widget artwork;
|
||||||
@@ -27,6 +28,7 @@ class MornyeLandscapePlayer extends StatefulWidget {
|
|||||||
final Widget volume;
|
final Widget volume;
|
||||||
final int page;
|
final int page;
|
||||||
final ValueChanged<int> onPageChanged;
|
final ValueChanged<int> onPageChanged;
|
||||||
|
final Widget? lyricsOptions;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MornyeLandscapePlayer> createState() => _MornyeLandscapePlayerState();
|
State<MornyeLandscapePlayer> createState() => _MornyeLandscapePlayerState();
|
||||||
@@ -193,6 +195,9 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
|
|||||||
widget.page == 1 ? 0 : 1,
|
widget.page == 1 ? 0 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.page == 1 &&
|
||||||
|
widget.lyricsOptions != null)
|
||||||
|
widget.lyricsOptions!,
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip:
|
tooltip:
|
||||||
context.l10n.nowPlayingUpNext,
|
context.l10n.nowPlayingUpNext,
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ import 'package:spotiflac_android/widgets/mini_player.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
const backendChannel = MethodChannel('com.zarz.spotiflac/backend');
|
const backendChannel = MethodChannel('com.zarz.spotiflac/backend');
|
||||||
|
const secureStorageChannel = MethodChannel(
|
||||||
|
'plugins.it_nomads.com/flutter_secure_storage',
|
||||||
|
);
|
||||||
late StreamController<MediaItem?> mediaItems;
|
late StreamController<MediaItem?> mediaItems;
|
||||||
late List<double> volumeWrites;
|
late List<double> volumeWrites;
|
||||||
late Map<String, dynamic> metadataOverrides;
|
late Map<String, dynamic> metadataOverrides;
|
||||||
@@ -50,6 +53,8 @@ void main() {
|
|||||||
volumeWrites = [];
|
volumeWrites = [];
|
||||||
metadataOverrides = {};
|
metadataOverrides = {};
|
||||||
metadataReads = [];
|
metadataReads = [];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(secureStorageChannel, (_) async => null);
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(backendChannel, (call) async {
|
.setMockMethodCallHandler(backendChannel, (call) async {
|
||||||
if (call.method != 'readFileMetadata') {
|
if (call.method != 'readFileMetadata') {
|
||||||
@@ -77,6 +82,8 @@ void main() {
|
|||||||
await mediaItems.close();
|
await mediaItems.close();
|
||||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
.setMockMethodCallHandler(backendChannel, null);
|
.setMockMethodCallHandler(backendChannel, null);
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(secureStorageChannel, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
MediaItem item(String id) => MediaItem(
|
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]) {
|
for (final mornye in [false, true]) {
|
||||||
testWidgets('player shows original, romanization and English ($mornye)', (
|
testWidgets('player shows original, romanization and English ($mornye)', (
|
||||||
tester,
|
tester,
|
||||||
@@ -1669,6 +1768,94 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
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(
|
testWidgets(
|
||||||
'original and romanization follow word timing and pauses ($mornye)',
|
'original and romanization follow word timing and pauses ($mornye)',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
@@ -1750,6 +1937,27 @@ void main() {
|
|||||||
expect(await pixelsAt(2900, text), orderedEquals(lastEnded));
|
expect(await pixelsAt(2900, text), orderedEquals(lastEnded));
|
||||||
expect(await pixelsAt(1100, text), orderedEquals(singingFirst));
|
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);
|
expect(tester.takeException(), isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user