From d273be026d8b150df068c19a64b1d417f3098352 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:04:09 +0700 Subject: [PATCH] feat(lyrics): customize text size and animate active lines --- lib/l10n/arb/app_en.arb | 2 + lib/l10n/arb/app_id.arb | 2 + lib/models/theme_settings.dart | 16 +++ lib/providers/theme_provider.dart | 9 ++ .../settings/lyrics_settings_page.dart | 2 + lib/widgets/lyrics_size_setting.dart | 61 ++++++++ lib/widgets/lyrics_text_scale.dart | 44 ++++++ lib/widgets/player_lyrics.dart | 39 ++--- lib/widgets/player_lyrics_section.dart | 15 +- test/lyrics_appearance_test.dart | 134 ++++++++++++++++++ 10 files changed, 302 insertions(+), 22 deletions(-) create mode 100644 lib/widgets/lyrics_size_setting.dart create mode 100644 lib/widgets/lyrics_text_scale.dart create mode 100644 test/lyrics_appearance_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5051fbd7..88db4f3e 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7156,6 +7156,8 @@ "appearanceCustomFontSubtitle": "Import a TTF or OTF file (up to 10 MB). Overrides the theme and system font.", "appearanceCustomFontInvalid": "Could not load this font. Choose a valid TTF or OTF file up to 10 MB.", "appearanceCustomFontReset": "Use theme font", + "lyricsTextSize": "Lyrics text size", + "lyricsTextPreview": "Follow every line of your favorite song", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index b134086f..042cfa76 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -7169,6 +7169,8 @@ "appearanceCustomFontSubtitle": "Impor file TTF atau OTF (maksimal 10 MB). Menggantikan font tema dan sistem.", "appearanceCustomFontInvalid": "Font tidak dapat dimuat. Pilih file TTF atau OTF yang valid, maksimal 10 MB.", "appearanceCustomFontReset": "Gunakan font tema", + "lyricsTextSize": "Ukuran teks lirik", + "lyricsTextPreview": "Ikuti setiap baris lagu favoritmu", "optionsEmbeddedCoverSizeOriginal": "Resolusi asli", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/models/theme_settings.dart b/lib/models/theme_settings.dart index 9970bacd..e14a1577 100644 --- a/lib/models/theme_settings.dart +++ b/lib/models/theme_settings.dart @@ -9,6 +9,10 @@ const String kMornyeAccentKey = 'mornye_accent'; const String kUseSystemFontKey = 'use_system_font'; const String kCustomFontIdKey = 'custom_font_id'; const String kCustomFontNameKey = 'custom_font_name'; +const String kLyricsTextScaleKey = 'lyrics_text_scale'; + +double normalizeLyricsTextScale(num? value) => + value != null && value.isFinite ? value.toDouble().clamp(0.8, 1.4) : 1.0; const String kMornyeGlassClarityKey = 'mornye_glass_clarity'; const String kMornyeGlassOutlinesKey = 'mornye_glass_outlines'; const double kDefaultMornyeGlassClarity = 0.75; @@ -47,6 +51,7 @@ class ThemeSettings { final bool useSystemFont; final String customFontId; final String customFontName; + final double lyricsTextScale; final double mornyeGlassClarity; final bool mornyeGlassOutlines; @@ -60,6 +65,7 @@ class ThemeSettings { this.useSystemFont = false, this.customFontId = '', this.customFontName = '', + this.lyricsTextScale = 1, this.mornyeGlassClarity = kDefaultMornyeGlassClarity, this.mornyeGlassOutlines = true, }); @@ -76,6 +82,7 @@ class ThemeSettings { bool? useSystemFont, String? customFontId, String? customFontName, + double? lyricsTextScale, double? mornyeGlassClarity, bool? mornyeGlassOutlines, }) { @@ -89,6 +96,9 @@ class ThemeSettings { useSystemFont: useSystemFont ?? this.useSystemFont, customFontId: customFontId ?? this.customFontId, customFontName: customFontName ?? this.customFontName, + lyricsTextScale: normalizeLyricsTextScale( + lyricsTextScale ?? this.lyricsTextScale, + ), mornyeGlassClarity: normalizeMornyeGlassClarity( mornyeGlassClarity ?? this.mornyeGlassClarity, ), @@ -106,6 +116,7 @@ class ThemeSettings { kUseSystemFontKey: useSystemFont, kCustomFontIdKey: customFontId, kCustomFontNameKey: customFontName, + kLyricsTextScaleKey: lyricsTextScale, kMornyeGlassClarityKey: mornyeGlassClarity, kMornyeGlassOutlinesKey: mornyeGlassOutlines, }; @@ -121,6 +132,9 @@ class ThemeSettings { useSystemFont: json[kUseSystemFontKey] as bool? ?? false, customFontId: json[kCustomFontIdKey] as String? ?? '', customFontName: json[kCustomFontNameKey] as String? ?? '', + lyricsTextScale: normalizeLyricsTextScale( + json[kLyricsTextScaleKey] as num?, + ), mornyeGlassClarity: normalizeMornyeGlassClarity( json[kMornyeGlassClarityKey] as num?, ), @@ -141,6 +155,7 @@ class ThemeSettings { other.useSystemFont == useSystemFont && other.customFontId == customFontId && other.customFontName == customFontName && + other.lyricsTextScale == lyricsTextScale && other.mornyeGlassClarity == mornyeGlassClarity && other.mornyeGlassOutlines == mornyeGlassOutlines; } @@ -156,6 +171,7 @@ class ThemeSettings { useSystemFont.hashCode ^ customFontId.hashCode ^ customFontName.hashCode ^ + lyricsTextScale.hashCode ^ mornyeGlassClarity.hashCode ^ mornyeGlassOutlines.hashCode; } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 76c41eaa..4fc7514a 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -18,6 +18,9 @@ ThemeSettings loadBootstrapThemeSettings(SharedPreferences prefs) { useSystemFont: prefs.getBool(kUseSystemFontKey) ?? false, customFontId: prefs.getString(kCustomFontIdKey) ?? '', customFontName: prefs.getString(kCustomFontNameKey) ?? '', + lyricsTextScale: normalizeLyricsTextScale( + prefs.getDouble(kLyricsTextScaleKey), + ), mornyeGlassClarity: normalizeMornyeGlassClarity( prefs.getDouble(kMornyeGlassClarityKey), ), @@ -81,6 +84,7 @@ class ThemeNotifier extends Notifier { await prefs.setBool(kUseSystemFontKey, snapshot.useSystemFont); await prefs.setString(kCustomFontIdKey, snapshot.customFontId); await prefs.setString(kCustomFontNameKey, snapshot.customFontName); + await prefs.setDouble(kLyricsTextScaleKey, snapshot.lyricsTextScale); await prefs.setDouble( kMornyeGlassClarityKey, snapshot.mornyeGlassClarity, @@ -136,6 +140,11 @@ class ThemeNotifier extends Notifier { await _saveToStorage(); } + Future setLyricsTextScale(double value, {bool persist = true}) async { + _editState((current) => current.copyWith(lyricsTextScale: value)); + if (persist) await _saveToStorage(); + } + Future setMornyeGlassClarity( double value, { bool persist = true, diff --git a/lib/screens/settings/lyrics_settings_page.dart b/lib/screens/settings/lyrics_settings_page.dart index 9c9bd66f..397b9bf2 100644 --- a/lib/screens/settings/lyrics_settings_page.dart +++ b/lib/screens/settings/lyrics_settings_page.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/lyrics_size_setting.dart'; class LyricsSettingsPage extends ConsumerWidget { const LyricsSettingsPage({super.key}); @@ -34,6 +35,7 @@ class LyricsSettingsPage extends ConsumerWidget { SliverToBoxAdapter( child: SettingsGroup( children: [ + const LyricsSizeSetting(), SettingsSwitchItem( icon: Icons.light_mode_outlined, title: context.l10n.lyricsKeepScreenOn, diff --git a/lib/widgets/lyrics_size_setting.dart b/lib/widgets/lyrics_size_setting.dart new file mode 100644 index 00000000..c53f1129 --- /dev/null +++ b/lib/widgets/lyrics_size_setting.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/widgets/lyrics_text_scale.dart'; + +class LyricsSizeSetting extends ConsumerWidget { + const LyricsSizeSetting({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scale = ref.watch( + themeProvider.select((theme) => theme.lyricsTextScale), + ); + final notifier = ref.read(themeProvider.notifier); + final percent = '${(scale * 100).round()}%'; + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + context.l10n.lyricsTextSize, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Text(percent), + IconButton( + tooltip: context.l10n.libraryFilterReset, + onPressed: scale == 1 + ? null + : () => notifier.setLyricsTextScale(1), + icon: const Icon(Icons.restart_alt), + ), + ], + ), + LyricsTextScale( + child: Text( + context.l10n.lyricsTextPreview, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + Slider( + value: scale, + min: 0.8, + max: 1.4, + divisions: 12, + label: percent, + semanticFormatterCallback: (value) => '${(value * 100).round()}%', + onChanged: (value) => + notifier.setLyricsTextScale(value, persist: false), + onChangeEnd: notifier.setLyricsTextScale, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/lyrics_text_scale.dart b/lib/widgets/lyrics_text_scale.dart new file mode 100644 index 00000000..59622947 --- /dev/null +++ b/lib/widgets/lyrics_text_scale.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; + +/// Applies the lyric preference on top of the system's accessibility scaling. +class LyricsTextScale extends ConsumerWidget { + const LyricsTextScale({super.key, required this.child}); + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final factor = ref.watch( + themeProvider.select((theme) => theme.lyricsTextScale), + ); + final media = MediaQuery.of(context); + return MediaQuery( + data: media.copyWith( + textScaler: LyricsTextScaler(media.textScaler, factor), + ), + child: child, + ); + } +} + +class LyricsTextScaler extends TextScaler { + const LyricsTextScaler(this.system, this.factor); + final TextScaler system; + final double factor; + + @override + double scale(double fontSize) => system.scale(fontSize) * factor; + + @override + double get textScaleFactor => scale(14) / 14; + + @override + bool operator ==(Object other) => + other is LyricsTextScaler && + other.system == system && + other.factor == factor; + + @override + int get hashCode => Object.hash(system, factor); +} diff --git a/lib/widgets/player_lyrics.dart b/lib/widgets/player_lyrics.dart index 78850a64..9979b011 100644 --- a/lib/widgets/player_lyrics.dart +++ b/lib/widgets/player_lyrics.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart'; import 'package:spotiflac_android/widgets/lyric_scroll_motion.dart'; import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart'; import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; +import 'package:spotiflac_android/widgets/lyrics_text_scale.dart'; const _mornyeLyricFontSize = 34.0; const mornyeLyricsContentInset = 28.0 + MornyePlayerSlider.horizontalInset; @@ -389,7 +390,7 @@ class _SyncedLyricsViewState extends ConsumerState super.dispose(); } - void _measureMornyeLines(double width) { + void _measureMornyeLines(BuildContext context, double width) { final style = _mornyeLyricStyle(context); final scaler = MediaQuery.textScalerOf(context); final direction = Directionality.of(context); @@ -557,18 +558,20 @@ class _SyncedLyricsViewState extends ConsumerState } @override - Widget build(BuildContext context) => TweenAnimationBuilder( - tween: Tween( - end: Offset( - widget.showPronunciation ? 1 : 0, - widget.showTranslation ? 1 : 0, + Widget build(BuildContext context) => LyricsTextScale( + child: TweenAnimationBuilder( + tween: Tween( + end: Offset( + widget.showPronunciation ? 1 : 0, + widget.showTranslation ? 1 : 0, + ), ), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 420), + curve: Curves.easeInOutCubic, + builder: (context, visibility, _) => _buildLyrics(context, visibility), ), - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 420), - curve: Curves.easeInOutCubic, - builder: (context, visibility, _) => _buildLyrics(context, visibility), ); Widget _buildLyrics(BuildContext context, Offset visibility) { @@ -636,7 +639,7 @@ class _SyncedLyricsViewState extends ConsumerState : 1.0; if (mornye) { final previousExtents = _lineExtents; - _measureMornyeLines(layoutWidth); + _measureMornyeLines(context, layoutWidth); // Reuse text measurements; only interpolate row heights as the // supplements fade. Scrolling follows the same animation clock. final fullExtents = List.generate(_lineMeasurements.length, (row) { @@ -897,15 +900,19 @@ class _SyncedLyricsViewState extends ConsumerState onTap: () => ref.read(musicPlayerControllerProvider).seek(line.time), child: AnimatedScale( - scale: mornye || isActive ? 1.0 : 0.96, - alignment: Alignment.center, - duration: const Duration(milliseconds: 280), + scale: isActive ? 1.0 : 0.96, + alignment: !mornye + ? Alignment.center + : textAlign == TextAlign.right + ? Alignment.centerRight + : Alignment.centerLeft, + duration: motion, curve: Curves.easeOutCubic, child: AnimatedOpacity( opacity: mornye ? (isActive || highContrast ? 1 : 0.48) : (isActive ? 1.0 : (isPast ? 0.55 : 0.85)), - duration: const Duration(milliseconds: 280), + duration: motion, child: content, ), ), diff --git a/lib/widgets/player_lyrics_section.dart b/lib/widgets/player_lyrics_section.dart index 0c77ed7a..3e74aa2e 100644 --- a/lib/widgets/player_lyrics_section.dart +++ b/lib/widgets/player_lyrics_section.dart @@ -7,6 +7,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/playback_seek_preview.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; import 'package:spotiflac_android/widgets/player_lyrics.dart'; +import 'package:spotiflac_android/widgets/lyrics_text_scale.dart'; class PlayerLyricsSection extends ConsumerWidget { const PlayerLyricsSection({ @@ -90,13 +91,15 @@ class PlayerLyricsSection extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - metadata.lyrics.plainText, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - height: 1.6, - color: colorScheme.onSurface, + LyricsTextScale( + child: Text( + metadata.lyrics.plainText, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + height: 1.6, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), ?_lyricsCredits(), ], diff --git a/test/lyrics_appearance_test.dart b/test/lyrics_appearance_test.dart new file mode 100644 index 00000000..a4f28375 --- /dev/null +++ b/test/lyrics_appearance_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/providers/theme_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/lyrics_parser.dart'; +import 'package:spotiflac_android/widgets/lyrics_size_setting.dart'; +import 'package:spotiflac_android/widgets/lyrics_text_scale.dart'; +import 'package:spotiflac_android/widgets/player_lyrics.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('lyric scaling respects accessibility and validates stored bounds', () { + const scale = LyricsTextScaler(TextScaler.linear(1.5), 1.2); + expect(scale.scale(20), 36); + expect(normalizeLyricsTextScale(double.nan), 1); + expect(normalizeLyricsTextScale(20), 1.4); + expect(normalizeLyricsTextScale(0), 0.8); + final settings = const ThemeSettings().copyWith(lyricsTextScale: 1.3); + expect(ThemeSettings.fromJson(settings.toJson()), settings); + }); + + testWidgets('size slider previews, persists on release and resets', ( + tester, + ) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: LyricsSizeSetting()), + ), + ), + ); + await tester.pumpAndSettle(); + final slider = tester.widget(find.byType(Slider)); + slider.onChanged!(1.3); + await tester.pump(); + expect(find.text('130%'), findsOneWidget); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getDouble(kLyricsTextScaleKey), isNull); + slider.onChangeEnd!(1.3); + await tester.pumpAndSettle(); + expect(loadBootstrapThemeSettings(prefs).lyricsTextScale, 1.3); + await tester.tap(find.byTooltip('Reset')); + await tester.pumpAndSettle(); + expect(loadBootstrapThemeSettings(prefs).lyricsTextScale, 1); + }); + + for (final reduced in [false, true]) { + testWidgets( + 'Mornye subtly scales active lyrics without changing layout (reduced=$reduced)', + (tester) async { + final preview = ValueNotifier(const Duration(seconds: 1)); + addTearDown(preview.dispose); + final theme = MornyeTheme.build(Brightness.dark); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: theme, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: reduced), + child: child!, + ), + home: Scaffold( + body: SyncedLyricsView( + lyrics: const ParsedLyrics( + synced: true, + wordSynced: false, + plainText: '', + lines: [ + LyricLine(time: Duration.zero, text: 'First line'), + LyricLine( + time: Duration(seconds: 10), + text: 'Second line', + ), + ], + ), + seekPreview: preview, + colorScheme: theme.colorScheme, + isActive: true, + showPronunciation: false, + showTranslation: false, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final scales = tester + .widgetList(find.byType(AnimatedScale)) + .toList(); + expect(scales.map((widget) => widget.scale), containsAll([1.0, 0.96])); + expect( + scales.first.duration, + reduced ? Duration.zero : const Duration(milliseconds: 280), + ); + final sizes = find + .byType(AnimatedScale) + .evaluate() + .map((e) => (e.renderObject! as RenderBox).size) + .toList(); + preview.value = const Duration(seconds: 11); + await tester.pumpAndSettle(); + expect( + tester + .widgetList(find.byType(AnimatedScale)) + .map((w) => w.scale) + .toList(), + scales.map((w) => w.scale).toList().reversed.toList(), + ); + expect( + find + .byType(AnimatedScale) + .evaluate() + .map((e) => (e.renderObject! as RenderBox).size) + .toList(), + sizes, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + } +}