mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
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:
@@ -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: '',
|
||||
),
|
||||
|
||||
@@ -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() ?? '';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user