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); + }); + }); }