fix(ui): align local lyrics and refine Mornye selection

Read embedded lyrics through the playback metadata fallback when the lyrics reader misses them. Restore edge icon highlighting during navbar drags, tune selection contrast, and blend the profile name field into its card.
This commit is contained in:
zarzet
2026-09-21 18:07:15 +07:00
parent d8d46108bc
commit 1c535b493c
7 changed files with 217 additions and 37 deletions
@@ -150,6 +150,8 @@ class _ProfileSettingsPageState extends ConsumerState<ProfileSettingsPage> {
decoration: InputDecoration(
labelText: context.l10n.profileName,
hintText: context.l10n.profileNameHint,
filled: false,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
counterText: '',
),
+26 -22
View File
@@ -207,6 +207,29 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
);
}
/// The lyrics reader can miss files outside the extension file grants.
/// Fall back to the same local tag reader used by Now Playing before
/// reporting that a file has no lyrics. Neither call fetches online lyrics.
Future<Map<String, dynamic>> _readLocalLyrics(String sourcePath) async {
try {
final result = await PlatformBridge.getLyricsLRCWithSource(
'',
trackName,
artistName,
filePath: sourcePath,
).timeout(const Duration(seconds: 5));
if (hasUsableLyricsContent(result['lyrics']?.toString() ?? '')) {
return result;
}
} catch (e) {
_log.d('Local lyrics reader failed; trying playback metadata: $e');
}
final metadata = await readPlaybackFileMetadataWithRetry(
sourcePath,
).timeout(const Duration(seconds: 5));
return {'lyrics': metadata['lyrics']?.toString() ?? '', 'source': ''};
}
/// Check for lyrics embedded in the audio file only (no network requests).
/// Called automatically when the screen opens.
Future<void> _checkEmbeddedLyrics() async {
@@ -223,17 +246,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
});
try {
final embeddedResult =
await PlatformBridge.getLyricsLRCWithSource(
'',
trackName,
artistName,
filePath: sourcePath,
durationMs: 0,
).timeout(
const Duration(seconds: 5),
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
);
final embeddedResult = await _readLocalLyrics(sourcePath);
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
final embeddedSource = embeddedResult['source']?.toString() ?? '';
@@ -273,6 +286,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
_setState(() {
_lyricsLoading = false;
_embeddedLyricsChecked = true;
_lyricsError = context.l10n.trackLyricsLoadFailed;
});
}
}
@@ -369,17 +383,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
final durationMs = (duration ?? 0) * 1000;
if (_fileExists) {
final embeddedResult =
await PlatformBridge.getLyricsLRCWithSource(
'',
trackName,
artistName,
filePath: cleanFilePath,
durationMs: 0,
).timeout(
const Duration(seconds: 5),
onTimeout: () => <String, dynamic>{'lyrics': '', 'source': ''},
);
final embeddedResult = await _readLocalLyrics(cleanFilePath);
final embeddedLyrics = embeddedResult['lyrics']?.toString() ?? '';
final embeddedSource = embeddedResult['source']?.toString() ?? '';
+2
View File
@@ -29,6 +29,8 @@ import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/music_player_service.dart'
show readPlaybackFileMetadataWithRetry;
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/replaygain_service.dart';
+15 -4
View File
@@ -107,7 +107,7 @@ class MornyeBottomBar extends ConsumerWidget {
scheme.brightness == Brightness.dark ? 0.5 : 0.4,
);
final player = MiniPlayer(compact: collapsed, bottomPadding: 0);
final tabs = TickerMode(
Widget tabs({required bool hideEdgeIcons}) => TickerMode(
enabled: !collapsed,
child: RepaintBoundary(
child: MornyeTabBar(
@@ -115,10 +115,15 @@ class MornyeBottomBar extends ConsumerWidget {
selectedIndex: selectedIndex,
onSelected: onSelected,
blurEnabled: blurEnabled,
hideEdgeIcons: true,
hideEdgeIcons: hideEdgeIcons,
),
),
);
// At rest the glass bar must paint its own edge icons: its selected layer
// follows a dragged pill before the destination is committed. Hand them
// to the moving overlays only while folding, at the same coordinates.
final fullTabs = tabs(hideEdgeIcons: false);
final foldingTabs = tabs(hideEdgeIcons: true);
return LayoutBuilder(
builder: (context, constraints) {
// Match the tab's actual label height, including accessibility scaling.
@@ -185,7 +190,10 @@ class MornyeBottomBar extends ConsumerWidget {
scheme.primary,
amount,
),
icon: destinations[index].icon,
icon: Opacity(
opacity: amount == 0 ? 0 : 1,
child: destinations[index].icon,
),
onPressed: home ? onHome : onSearch,
),
),
@@ -237,7 +245,10 @@ class MornyeBottomBar extends ConsumerWidget {
ignoring: amount > 0.5,
child: ExcludeSemantics(
excluding: amount > 0.5,
child: Opacity(opacity: 1 - amount, child: tabs),
child: Opacity(
opacity: 1 - amount,
child: amount == 0 ? fullTabs : foldingTabs,
),
),
),
),
+11 -5
View File
@@ -451,6 +451,9 @@ class MornyeTabBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final selectionFill = scheme.onSurface.withValues(
alpha: scheme.brightness == Brightness.dark ? 0.12 : 0.08,
);
final inactiveIconColor = Color.lerp(
scheme.onSurfaceVariant,
scheme.onSurface,
@@ -510,12 +513,13 @@ class MornyeTabBar extends StatelessWidget {
iconSize: 25,
labelFontSize: 11,
),
pillStyle: const LiquidGlassTabPillStyle(
pillStyle: LiquidGlassTabPillStyle(
mode: LiquidGlassPillMode.impellerOnly,
color: selectionFill,
animated: true,
// Keep the moving refractive pill, without stacking the
// package's second magnifier lens beneath it.
magnifierPill: LiquidGlassTabMagnifierPillStyle(
magnifierPill: const LiquidGlassTabMagnifierPillStyle(
enabled: false,
),
),
@@ -548,7 +552,7 @@ class MornyeTabBar extends StatelessWidget {
?.copyWith(
fontSize: label.textStyle.fontSize,
fontWeight: FontWeight.w600,
color: scheme.onSurface,
color: label.textStyle.color,
),
),
),
@@ -591,7 +595,7 @@ class MornyeTabBar extends StatelessWidget {
),
decoration: BoxDecoration(
color: index == selectedIndex
? scheme.onSurface.withValues(alpha: 0.09)
? selectionFill
: Colors.transparent,
borderRadius: BorderRadius.circular(28),
),
@@ -625,7 +629,9 @@ class MornyeTabBar extends StatelessWidget {
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: scheme.onSurface,
color: index == selectedIndex
? scheme.primary
: scheme.onSurface,
),
),
],
+77 -6
View File
@@ -45,6 +45,7 @@ void main() {
Color? backdrop,
Color? chromeSurface,
Widget? body,
ValueNotifier<int>? activeTab,
}) async {
tester.view.physicalSize = const Size(393, 760);
tester.view.devicePixelRatio = 1;
@@ -87,10 +88,10 @@ void main() {
itemBuilder: (_, index) => Text('Row $index'),
),
),
bottomNavigationBar: ValueListenableBuilder<bool>(
valueListenable: chrome,
builder: (_, collapsed, _) => MornyeBottomBar(
collapsed: collapsed,
bottomNavigationBar: ListenableBuilder(
listenable: Listenable.merge([chrome, ?activeTab]),
builder: (_, _) => MornyeBottomBar(
collapsed: chrome.value,
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(
@@ -106,8 +107,11 @@ void main() {
label: 'Search',
),
],
selectedIndex: 0,
onSelected: (index) => selected = index,
selectedIndex: activeTab?.value ?? 0,
onSelected: (index) {
selected = index;
activeTab?.value = index;
},
onHome: chrome.expand,
onSearch: () => searches++,
blurEnabled: blur,
@@ -123,6 +127,73 @@ void main() {
const albumBlue = Color(0xff464566);
for (final blur in [false, true]) {
testWidgets('active edge icons and tabs stay synchronized (glass: $blur)', (
tester,
) async {
final activeTab = ValueNotifier(0);
addTearDown(activeTab.dispose);
await pumpShell(tester, blur: blur, activeTab: activeTab);
final primary = MornyeTheme.build(Brightness.light).colorScheme.primary;
Color? iconColor(String key, IconData data) {
final icon = find.descendant(
of: find.byKey(ValueKey(key)),
matching: find.byIcon(data),
);
return IconTheme.of(tester.element(icon)).color;
}
for (final (index, label) in [
(1, 'Library'),
(3, 'Search'),
(0, 'Home'),
]) {
await tester.tapAt(
tester.getCenter(
find
.descendant(
of: find.byType(MornyeTabBar),
matching: find.text(label),
)
.first,
),
);
await tester.pumpAndSettle();
expect(activeTab.value, index);
final tabIcons = find.descendant(
of: find.byType(MornyeTabBar),
matching: find.byIcon(index == 3 ? Icons.search : Icons.home),
);
// Edge icons stay in the glass bar's selected/unselected layers at
// rest, so the pill can reveal their red state during a held drag.
for (final icon in tabIcons.evaluate()) {
for (final opacity in tester.widgetList<Opacity>(
find.ancestor(
of: find.byElementPredicate((e) => e == icon),
matching: find.byType(Opacity),
),
)) {
expect(opacity.opacity, 1);
}
}
expect(
iconColor('mornye-compact-home', Icons.home),
index == 0 ? primary : isNot(primary),
);
expect(
iconColor('mornye-compact-search', Icons.search),
index == 3 ? primary : isNot(primary),
);
chrome.value = true;
await tester.pumpAndSettle();
chrome.expand();
await tester.pumpAndSettle();
expect(
tester.widget<MornyeTabBar>(find.byType(MornyeTabBar)).selectedIndex,
index,
);
}
});
testWidgets(
'edge icons travel continuously without fading (glass: $blur)',
(tester) async {
+84
View File
@@ -0,0 +1,84 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/providers/download_history_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
for (final scenario in ['fallback', 'local', 'empty', 'unavailable']) {
testWidgets('metadata reads local lyrics: $scenario', (tester) async {
SharedPreferences.setMockInitialValues({});
const path = 'content://library/document/song.flac';
const lyrics = '[00:01.00]A locally stored lyric line';
var metadataReads = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
switch (call.method) {
case 'safStat':
return jsonEncode({'exists': true, 'size': 100});
case 'readAudioMetadata':
return '{}';
case 'getLyricsLRCWithSource':
// Opening metadata must never trigger an online lyrics request.
expect((call.arguments as Map)['file_path'], path);
if (scenario == 'unavailable') {
throw PlatformException(code: 'backend_unavailable');
}
return jsonEncode({
'lyrics': scenario == 'local' ? lyrics : '',
'source': scenario == 'local' ? 'Embedded' : '',
});
case 'readFileMetadata':
expect((call.arguments as Map)['file_path'], path);
metadataReads++;
return jsonEncode({'lyrics': scenario == 'empty' ? '' : lyrics});
case 'getSafFileModTimes':
return '{}';
default:
return null;
}
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final item = DownloadHistoryItem(
id: 'local-song',
trackName: 'Song',
artistName: 'Artist',
albumName: 'Album',
filePath: path,
service: 'example-provider',
downloadedAt: DateTime(2026),
format: 'flac',
);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: TrackMetadataScreen(item: item),
),
),
);
await tester.pumpAndSettle();
expect(metadataReads, scenario == 'local' ? 0 : 1);
expect(
find.text('A locally stored lyric line'),
scenario == 'empty' ? findsNothing : findsOneWidget,
);
expect(
find.text('No lyrics found in this file'),
scenario == 'empty' ? findsOneWidget : findsNothing,
);
expect(find.text('Embed Lyrics'), findsNothing);
expect(tester.takeException(), isNull);
});
}
}