mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
feat(player): search lyrics online for files without them
The empty lyrics page offers Search online, which asks the enabled lyrics providers in their configured order. The result is shown in the player only and never written to the file; a late result is dropped after the track changes, and a miss says so.
This commit is contained in:
1 parent
56ad01ec2c
commit
dbee3d56cb
5 files changed
+170
-3
No files matched your search
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/playback_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -9,17 +10,36 @@ import 'package:spotiflac_android/utils/lyrics_parser.dart';
|
||||
|
||||
final _log = AppLogger('PlayerMetadata');
|
||||
|
||||
/// Returns LRC text from the configured lyrics providers, or empty.
|
||||
typedef OnlineLyricsFetcher = Future<String> Function(MediaItem item);
|
||||
|
||||
Future<String> _fetchOnlineLyrics(MediaItem item) async {
|
||||
final result = await PlatformBridge.getLyricsLRCWithSource(
|
||||
'',
|
||||
item.title,
|
||||
item.artist ?? '',
|
||||
durationMs: item.duration?.inMilliseconds ?? 0,
|
||||
).timeout(const Duration(seconds: 30));
|
||||
return result['lyrics']?.toString() ?? '';
|
||||
}
|
||||
|
||||
/// Owns file metadata and lyrics for the full player. Requests are scoped to
|
||||
/// the current queue item so a late probe cannot replace another track's data.
|
||||
class PlayerMetadataController extends ChangeNotifier {
|
||||
PlayerMetadataController({
|
||||
PlaybackMetadataReader? readMetadata,
|
||||
Future<ParsedLyrics> Function(String?)? parseLyrics,
|
||||
OnlineLyricsFetcher? fetchOnlineLyrics,
|
||||
}) : _readMetadata = readMetadata ?? readPlaybackFileMetadataWithRetry,
|
||||
_parseLyrics = parseLyrics ?? LyricsParser.parseAsyncNative;
|
||||
_parseLyrics = parseLyrics ?? LyricsParser.parseAsyncNative,
|
||||
_fetchOnline = fetchOnlineLyrics ?? _fetchOnlineLyrics;
|
||||
|
||||
final PlaybackMetadataReader _readMetadata;
|
||||
final Future<ParsedLyrics> Function(String?) _parseLyrics;
|
||||
final OnlineLyricsFetcher _fetchOnline;
|
||||
MediaItem? _item;
|
||||
bool _searchingOnline = false;
|
||||
bool _onlineSearchMissed = false;
|
||||
(String, String, String?)? _requestKey;
|
||||
String? _loadedPath;
|
||||
Map<String, dynamic> _fallback = const {};
|
||||
@@ -40,6 +60,45 @@ class PlayerMetadataController extends ChangeNotifier {
|
||||
Map<String, dynamic>? get metadata => _metadata;
|
||||
ParsedLyrics get lyrics => _lyrics;
|
||||
bool get loading => _loading;
|
||||
bool get searchingOnline => _searchingOnline;
|
||||
|
||||
/// The last online search for this track found nothing.
|
||||
bool get onlineSearchMissed => _onlineSearchMissed;
|
||||
|
||||
bool get canSearchOnline =>
|
||||
(_item?.title.trim().isNotEmpty ?? false) &&
|
||||
(_item?.artist?.trim().isNotEmpty ?? false);
|
||||
|
||||
/// Shows provider lyrics for a file without its own. Nothing is written to
|
||||
/// the file; changing tracks discards the result.
|
||||
Future<void> searchOnline() async {
|
||||
final item = _item;
|
||||
if (_disposed || item == null || _searchingOnline || !canSearchOnline) {
|
||||
return;
|
||||
}
|
||||
final generation = _generation;
|
||||
_searchingOnline = true;
|
||||
_onlineSearchMissed = false;
|
||||
notifyListeners();
|
||||
var parsed = ParsedLyrics.empty;
|
||||
var raw = '';
|
||||
try {
|
||||
raw = await _fetchOnline(item);
|
||||
if (_disposed || generation != _generation) return;
|
||||
if (raw.trim().isNotEmpty) parsed = await _parseLyrics(raw);
|
||||
} catch (error) {
|
||||
_log.w('Failed to search lyrics online: $error');
|
||||
}
|
||||
if (_disposed || generation != _generation) return;
|
||||
_searchingOnline = false;
|
||||
if (parsed.isEmpty) {
|
||||
_onlineSearchMissed = true;
|
||||
} else {
|
||||
_lyrics = parsed;
|
||||
_lyricsRaw = raw;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> load(
|
||||
MediaItem? item, {
|
||||
@@ -48,9 +107,11 @@ class PlayerMetadataController extends ChangeNotifier {
|
||||
if (_disposed) return;
|
||||
final source = item?.extras?['source']?.toString() ?? '';
|
||||
if (item == null || source.isEmpty) {
|
||||
_item = null;
|
||||
if (_requestKey == null) return;
|
||||
_generation++;
|
||||
_probePending = false;
|
||||
_resetOnlineSearch();
|
||||
_requestKey = null;
|
||||
_loadedPath = null;
|
||||
final pending = _replaceMetadata(const {});
|
||||
@@ -60,10 +121,12 @@ class PlayerMetadataController extends ChangeNotifier {
|
||||
final resolvedSource = item.extras?['resolvedSource']?.toString().trim();
|
||||
final key = (item.id, source, resolvedSource);
|
||||
final sameItem = key == _requestKey;
|
||||
_item = item;
|
||||
_fallback = Map.unmodifiable(playbackAudioMetadataFromMediaItem(item));
|
||||
if (!sameItem) {
|
||||
_generation++;
|
||||
_probePending = false;
|
||||
_resetOnlineSearch();
|
||||
_requestKey = key;
|
||||
_loadedPath = null;
|
||||
}
|
||||
@@ -165,6 +228,11 @@ class PlayerMetadataController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _resetOnlineSearch() {
|
||||
_searchingOnline = false;
|
||||
_onlineSearchMissed = false;
|
||||
}
|
||||
|
||||
String _metadataPath(String source, String? resolved) =>
|
||||
!source.startsWith('network://') && resolved?.isNotEmpty == true
|
||||
? resolved!
|
||||
|
||||
@@ -6812,6 +6812,14 @@
|
||||
"@nowPlayingNoLyrics": {
|
||||
"description": "Empty state when the playing file has no embedded lyrics"
|
||||
},
|
||||
"nowPlayingSearchLyricsOnline": "Search online",
|
||||
"@nowPlayingSearchLyricsOnline": {
|
||||
"description": "Button in the player's empty lyrics state that looks up lyrics from the enabled lyrics providers"
|
||||
},
|
||||
"nowPlayingNoLyricsOnline": "No lyrics found online",
|
||||
"@nowPlayingNoLyricsOnline": {
|
||||
"description": "Empty state after an online lyrics search for the playing track found nothing"
|
||||
},
|
||||
"nowPlayingLibraryEmpty": "Your library is empty",
|
||||
"@nowPlayingLibraryEmpty": {
|
||||
"description": "Snackbar when shuffle library is requested but library has no tracks"
|
||||
|
||||
@@ -6824,6 +6824,14 @@
|
||||
"@nowPlayingNoLyrics": {
|
||||
"description": "Empty state when the playing file has no embedded lyrics"
|
||||
},
|
||||
"nowPlayingSearchLyricsOnline": "Cari online",
|
||||
"@nowPlayingSearchLyricsOnline": {
|
||||
"description": "Button in the player's empty lyrics state that looks up lyrics from the enabled lyrics providers"
|
||||
},
|
||||
"nowPlayingNoLyricsOnline": "Lirik tidak ditemukan online",
|
||||
"@nowPlayingNoLyricsOnline": {
|
||||
"description": "Empty state after an online lyrics search for the playing track found nothing"
|
||||
},
|
||||
"nowPlayingLibraryEmpty": "Koleksi Anda kosong",
|
||||
"@nowPlayingLibraryEmpty": {
|
||||
"description": "Snackbar when shuffle library is requested but library has no tracks"
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/utils/lyrics_parser.dart';
|
||||
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
|
||||
import 'package:spotiflac_android/widgets/app_icon.dart';
|
||||
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
|
||||
import 'package:spotiflac_android/widgets/expressive_button.dart';
|
||||
import 'package:spotiflac_android/widgets/player_lyrics.dart';
|
||||
import 'package:spotiflac_android/widgets/lyrics_text_scale.dart';
|
||||
|
||||
@@ -47,7 +48,7 @@ class PlayerLyricsSection extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildLyricsSection(BuildContext context, (bool, bool) visibility) {
|
||||
if (metadata.loading) {
|
||||
if (metadata.loading || metadata.searchingOnline) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (metadata.lyrics.isEmpty) {
|
||||
@@ -62,11 +63,22 @@ class PlayerLyricsSection extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.nowPlayingNoLyrics,
|
||||
metadata.onlineSearchMissed
|
||||
? context.l10n.nowPlayingNoLyricsOnline
|
||||
: context.l10n.nowPlayingNoLyrics,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (metadata.canSearchOnline) ...[
|
||||
const SizedBox(height: 16),
|
||||
ExpressiveButton(
|
||||
tonal: true,
|
||||
onPressed: metadata.searchOnline,
|
||||
icon: const Icon(Icons.manage_search),
|
||||
child: Text(context.l10n.nowPlayingSearchLyricsOnline),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -342,4 +342,75 @@ void main() {
|
||||
expect(controller.lyrics.lines.single.text, 'Current');
|
||||
},
|
||||
);
|
||||
|
||||
group('online lyrics search', () {
|
||||
PlayerMetadataController controllerFor(
|
||||
Future<String> Function(MediaItem) fetch,
|
||||
) {
|
||||
final controller = PlayerMetadataController(
|
||||
readMetadata: (_) async => const {},
|
||||
parseLyrics: (raw) async => LyricsParser.parse(raw),
|
||||
fetchOnlineLyrics: fetch,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
return controller;
|
||||
}
|
||||
|
||||
test('shows provider lyrics for a file without its own', () async {
|
||||
final requested = <String>[];
|
||||
final controller = controllerFor((item) async {
|
||||
requested.add(item.title);
|
||||
return '[00:01.00]Found';
|
||||
});
|
||||
await controller.load(_item('a'));
|
||||
expect(controller.lyrics.isEmpty, isTrue);
|
||||
expect(controller.canSearchOnline, isTrue);
|
||||
final search = controller.searchOnline();
|
||||
expect(controller.searchingOnline, isTrue);
|
||||
await search;
|
||||
expect(requested, ['a']);
|
||||
expect(controller.searchingOnline, isFalse);
|
||||
expect(controller.onlineSearchMissed, isFalse);
|
||||
expect(controller.lyrics.lines.single.text, 'Found');
|
||||
});
|
||||
|
||||
test('a miss or failure is reported until the track changes', () async {
|
||||
var attempt = 0;
|
||||
final controller = controllerFor((_) async {
|
||||
if (attempt++ == 0) return '';
|
||||
throw StateError('offline');
|
||||
});
|
||||
await controller.load(_item('a'));
|
||||
await controller.searchOnline();
|
||||
expect(controller.onlineSearchMissed, isTrue);
|
||||
await controller.searchOnline();
|
||||
expect(controller.onlineSearchMissed, isTrue);
|
||||
expect(controller.lyrics.isEmpty, isTrue);
|
||||
await controller.load(_item('b'));
|
||||
expect(controller.onlineSearchMissed, isFalse);
|
||||
});
|
||||
|
||||
test('a late result cannot replace the next track', () async {
|
||||
final result = Completer<String>();
|
||||
final controller = controllerFor((_) => result.future);
|
||||
await controller.load(_item('a'));
|
||||
final search = controller.searchOnline();
|
||||
await controller.load(_item('b'));
|
||||
expect(controller.searchingOnline, isFalse);
|
||||
result.complete('[00:01.00]Old track');
|
||||
await search;
|
||||
expect(controller.lyrics.isEmpty, isTrue);
|
||||
expect(controller.onlineSearchMissed, isFalse);
|
||||
});
|
||||
|
||||
test('needs a title and artist to search', () async {
|
||||
final controller = controllerFor((_) async => '[00:01.00]Found');
|
||||
await controller.load(
|
||||
MediaItem(id: 'a', title: 'a', extras: const {'source': '/a.flac'}),
|
||||
);
|
||||
expect(controller.canSearchOnline, isFalse);
|
||||
await controller.searchOnline();
|
||||
expect(controller.lyrics.isEmpty, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user