feat(lyrics): customize text size and animate active lines

This commit is contained in:
zarzet committed 2026-10-10 17:04:09 +07:00
1 parent b30dd9fed0
commit d273be026d
10 files changed
+302 -22

No files matched your search

+2
View File
@@ -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"
+2
View File
@@ -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"
+16
View File
@@ -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;
}
+9
View File
@@ -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<ThemeSettings> {
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<ThemeSettings> {
await _saveToStorage();
}
Future<void> setLyricsTextScale(double value, {bool persist = true}) async {
_editState((current) => current.copyWith(lyricsTextScale: value));
if (persist) await _saveToStorage();
}
Future<void> setMornyeGlassClarity(
double value, {
bool persist = true,
@@ -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,
+61
View File
@@ -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,
),
],
),
);
}
}
+44
View File
@@ -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);
}
+23 -16
View File
@@ -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<SyncedLyricsView>
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<SyncedLyricsView>
}
@override
Widget build(BuildContext context) => TweenAnimationBuilder<Offset>(
tween: Tween(
end: Offset(
widget.showPronunciation ? 1 : 0,
widget.showTranslation ? 1 : 0,
Widget build(BuildContext context) => LyricsTextScale(
child: TweenAnimationBuilder<Offset>(
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<SyncedLyricsView>
: 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<SyncedLyricsView>
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,
),
),
+9 -6
View File
@@ -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(),
],
+134
View File
@@ -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<Slider>(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<Duration?>(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<AnimatedScale>(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<AnimatedScale>(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());
},
);
}
}