From e0ad91b9d175faae7fb131582a0287639c665559 Mon Sep 17 00:00:00 2001 From: nokarin-dev Date: Mon, 18 May 2026 19:04:11 +0700 Subject: [PATCH 1/2] feat: add lrclib fallback for lyrics --- lib/main.dart | 2 +- lib/providers/lyrics_provider.dart | 85 ++++++++++++++++++++++++++++-- lib/screens/player_screen.dart | 7 +-- lib/services/audio_service.dart | 10 ++-- lib/util/logger.dart | 19 ++++--- lib/widgets/lyrics_view.dart | 1 + 6 files changed, 105 insertions(+), 19 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 4f0fbee..91767d5 100755 --- a/lib/main.dart +++ b/lib/main.dart @@ -53,7 +53,7 @@ void main() async { settings: settings, ); } catch (e, st) { - print('[aqloss] main init error: $e\n$st'); + Logger.errorFrontend('[aqloss] main init error: $e\n$st'); } }); } diff --git a/lib/providers/lyrics_provider.dart b/lib/providers/lyrics_provider.dart index dfd0a0b..b8c7d43 100755 --- a/lib/providers/lyrics_provider.dart +++ b/lib/providers/lyrics_provider.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:aqloss/util/logger.dart'; import 'package:flutter_riverpod/legacy.dart'; +import 'package:http/http.dart' as http; import 'package:aqloss/providers/player_provider.dart'; import 'package:aqloss/src/rust/api.dart' as backend; @@ -77,13 +80,18 @@ class LyricsState { bool get hasSynced => document != null; } -enum LyricsSource { none, embedded, lrcFile, txtFile } +enum LyricsSource { none, embedded, lrcFile, txtFile, lrclib } // Notifier class LyricsNotifier extends StateNotifier { LyricsNotifier() : super(const LyricsState()); - Future loadForTrack(String trackPath) async { + Future loadForTrack( + String trackPath, { + String? artist, + String? title, + int? duration, + }) async { if (state.trackPath == trackPath) return; state = LyricsState(isLoading: true, trackPath: trackPath); @@ -152,6 +160,24 @@ class LyricsNotifier extends StateNotifier { } } + // lrclib fallback + if (artist != null && title != null) { + final lrclibResult = await _fetchFromLrclib( + artist: artist, + title: title, + duration: duration, + ); + if (lrclibResult != null) { + state = LyricsState( + document: lrclibResult.document, + rawText: lrclibResult.rawText, + trackPath: trackPath, + source: LyricsSource.lrclib, + ); + return; + } + } + state = LyricsState(trackPath: trackPath); } catch (_) { state = LyricsState(trackPath: trackPath); @@ -163,6 +189,54 @@ class LyricsNotifier extends StateNotifier { } } +// lrclib result container +class _LrclibResult { + final LrcDocument? document; + final String? rawText; + const _LrclibResult({this.document, this.rawText}); +} + +Future<_LrclibResult?> _fetchFromLrclib({ + required String artist, + required String title, + int? duration, +}) async { + try { + final uri = Uri.https('lrclib.net', '/api/get', { + 'artist_name': artist, + 'track_name': title, + if (duration != null) 'duration': duration.toString(), + }); + Logger.infoFrontend("Searching for $title lyrics: $uri"); + + final res = await http + .get( + uri, + headers: { + 'User-Agent': 'aqloss/1.0 (https://nokarin.xyz/projects/aqloss)', + }, + ) + .timeout(const Duration(seconds: 8)); + + if (res.statusCode != 200) return null; + + final json = jsonDecode(res.body) as Map; + + // Prefer synced LRC over plain text + final syncedLyrics = json['syncedLyrics'] as String?; + if (syncedLyrics != null && syncedLyrics.trim().isNotEmpty) { + final doc = LrcDocument.parse(syncedLyrics); + if (doc != null) return _LrclibResult(document: doc); + } + + final plainLyrics = json['plainLyrics'] as String?; + if (plainLyrics != null && plainLyrics.trim().isNotEmpty) { + return _LrclibResult(rawText: plainLyrics.trim()); + } + } catch (_) {} + return null; +} + final lyricsProvider = StateNotifierProvider(( ref, ) { @@ -170,7 +244,12 @@ final lyricsProvider = StateNotifierProvider(( ref.listen(playerProvider, (prev, next) { final path = next.currentTrack?.path; if (path != null && path != prev?.currentTrack?.path) { - notifier.loadForTrack(path); + notifier.loadForTrack( + path, + artist: next.currentTrack?.artist, + title: next.currentTrack?.title, + duration: next.currentTrack?.duration.inSeconds, + ); } else if (path == null) { notifier.clear(); } diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart index 4103ef6..e8afe21 100755 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -35,17 +35,18 @@ class _WideLayout extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final hasLyrics = ref.watch(lyricsProvider).hasLyrics; + ref.watch(lyricsProvider); final settings = ref.watch(settingsProvider); final cs = Theme.of(context).colorScheme; final width = MediaQuery.of(context).size.width; + final showLyricsPanel = track != null; return Row( children: [ AnimatedContainer( duration: const Duration(milliseconds: 320), curve: Curves.easeInOutCubic, - width: hasLyrics ? width * 0.28 : width * 0.44, + width: showLyricsPanel ? width * 0.28 : width * 0.44, child: Padding( padding: const EdgeInsets.fromLTRB(28, 32, 16, 24), child: Column( @@ -58,7 +59,7 @@ class _WideLayout extends ConsumerWidget { showBackground: settings.showAlbumArtBackground, ), ), - if (hasLyrics) ...[ + if (showLyricsPanel) ...[ const SizedBox(height: 14), Expanded( child: ClipRRect( diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 7d444c9..2191283 100755 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -61,7 +61,7 @@ class AudioService { await Future.delayed(Duration(milliseconds: delays[attempt])); } try { - print('[aqloss] initEngine attempt ${attempt + 1} start'); + Logger.debugAudioService('engine attempt ${attempt + 1} start'); if (deviceId != null) { await backend .initEngineWithDevice(deviceId: deviceId, exclusive: exclusive) @@ -69,22 +69,20 @@ class AudioService { } else { await backend.initEngine().timeout(const Duration(seconds: 8)); } - print('[aqloss] initEngine attempt ${attempt + 1} SUCCESS'); + Logger.debugAudioService('engine attempt ${attempt + 1} SUCCESS'); _engineReady = true; Logger.debugAudioService('engine ready (attempt ${attempt + 1})'); break; } catch (e, st) { - print('[aqloss] initEngine attempt ${attempt + 1} FAILED: $e'); Logger.errorAudioService('init attempt ${attempt + 1} FAILED: $e\n$st'); if (attempt == delays.length - 1) { try { - print('[aqloss] initEngine fallback start'); + Logger.debugAudioService('initEngine fallback start'); await backend.initEngine().timeout(const Duration(seconds: 6)); - print('[aqloss] initEngine fallback SUCCESS'); + Logger.debugAudioService('[aqloss] initEngine fallback SUCCESS'); _engineReady = true; Logger.debugAudioService('engine ready (fallback shared)'); } catch (e2, st2) { - print('[aqloss] initEngine FATAL: $e2'); Logger.errorAudioService('engine init FATAL: $e2\n$st2'); return; } diff --git a/lib/util/logger.dart b/lib/util/logger.dart index dc18c5b..ce3b7d1 100644 --- a/lib/util/logger.dart +++ b/lib/util/logger.dart @@ -17,7 +17,8 @@ enum LogTarget { audioService('audio_service.log'), lastfm('lastfm.log'), deviceProdiver('device_provider.log'), - playerProvider('player_provider.log'); + playerProvider('player_provider.log'), + frontend('frontend.log'); final String fileName; const LogTarget(this.fileName); @@ -56,11 +57,7 @@ class Logger { ──────────────────────────────────────────────────────\n"""; for (var file in _files.values) { - await file.writeAsString( - startSession, - mode: FileMode.write, - flush: true, - ); + await file.writeAsString(startSession, mode: FileMode.write, flush: true); } _isInitialized = true; @@ -136,4 +133,14 @@ class Logger { _instance._log(LogTarget.playerProvider, LogLevel.warn, msg); static void errorPlayerProvider(String msg) => _instance._log(LogTarget.playerProvider, LogLevel.error, msg); + + // Frontend + static void debugFrontend(String msg) => + _instance._log(LogTarget.frontend, LogLevel.debug, msg); + static void infoFrontend(String msg) => + _instance._log(LogTarget.frontend, LogLevel.info, msg); + static void warnFrontend(String msg) => + _instance._log(LogTarget.frontend, LogLevel.warn, msg); + static void errorFrontend(String msg) => + _instance._log(LogTarget.frontend, LogLevel.error, msg); } diff --git a/lib/widgets/lyrics_view.dart b/lib/widgets/lyrics_view.dart index eb1094c..edddd74 100755 --- a/lib/widgets/lyrics_view.dart +++ b/lib/widgets/lyrics_view.dart @@ -262,6 +262,7 @@ class _SourceBadge extends StatelessWidget { LyricsSource.embedded => (Icons.music_note, 'Embedded'), LyricsSource.lrcFile => (Icons.text_snippet_outlined, '.lrc file'), LyricsSource.txtFile => (Icons.text_fields, '.txt file'), + LyricsSource.lrclib => (Icons.cloud_outlined, 'lrclib'), LyricsSource.none => (Icons.close, ''), }; From ae749121d8045a46fe4b7509b78e376b909e53b6 Mon Sep 17 00:00:00 2001 From: nokarin-dev Date: Mon, 18 May 2026 19:04:31 +0700 Subject: [PATCH 2/2] feat: add lrclib fallback for lyrics --- changes.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changes.md b/changes.md index a07b62b..0aaaa3d 100755 --- a/changes.md +++ b/changes.md @@ -10,7 +10,8 @@ This project loosely follows Keep a Changelog and uses Semantic Versioning. ### Added -- find button discord RPC now links to YouTube Music search +- Find button discord RPC now links to YouTube Music search +- Added irclib fallback for lyrics - [Android] Storage permissions handler - [Android] URI path resolution - [Android] Folder manager access on mobile