From dbee3d56cb92d4eb451db5e420208eb71c422033 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:24:39 +0700 Subject: [PATCH] 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. --- .../player_metadata_controller.dart | 70 +++++++++++++++++- lib/l10n/arb/app_en.arb | 8 +++ lib/l10n/arb/app_id.arb | 8 +++ lib/widgets/player_lyrics_section.dart | 16 ++++- test/player_metadata_controller_test.dart | 71 +++++++++++++++++++ 5 files changed, 170 insertions(+), 3 deletions(-) diff --git a/lib/controllers/player_metadata_controller.dart b/lib/controllers/player_metadata_controller.dart index 5bcfad13..9e5230a2 100644 --- a/lib/controllers/player_metadata_controller.dart +++ b/lib/controllers/player_metadata_controller.dart @@ -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 Function(MediaItem item); + +Future _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 Function(String?)? parseLyrics, + OnlineLyricsFetcher? fetchOnlineLyrics, }) : _readMetadata = readMetadata ?? readPlaybackFileMetadataWithRetry, - _parseLyrics = parseLyrics ?? LyricsParser.parseAsyncNative; + _parseLyrics = parseLyrics ?? LyricsParser.parseAsyncNative, + _fetchOnline = fetchOnlineLyrics ?? _fetchOnlineLyrics; final PlaybackMetadataReader _readMetadata; final Future Function(String?) _parseLyrics; + final OnlineLyricsFetcher _fetchOnline; + MediaItem? _item; + bool _searchingOnline = false; + bool _onlineSearchMissed = false; (String, String, String?)? _requestKey; String? _loadedPath; Map _fallback = const {}; @@ -40,6 +60,45 @@ class PlayerMetadataController extends ChangeNotifier { Map? 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 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 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! diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 0687aa7a..138d4d21 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 2c6baf83..ff3e3575 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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" diff --git a/lib/widgets/player_lyrics_section.dart b/lib/widgets/player_lyrics_section.dart index 0fbcc046..9098774c 100644 --- a/lib/widgets/player_lyrics_section.dart +++ b/lib/widgets/player_lyrics_section.dart @@ -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), + ), + ], ], ), ); diff --git a/test/player_metadata_controller_test.dart b/test/player_metadata_controller_test.dart index 28afd623..b00f7e7f 100644 --- a/test/player_metadata_controller_test.dart +++ b/test/player_metadata_controller_test.dart @@ -342,4 +342,75 @@ void main() { expect(controller.lyrics.lines.single.text, 'Current'); }, ); + + group('online lyrics search', () { + PlayerMetadataController controllerFor( + Future 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 = []; + 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(); + 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); + }); + }); }