diff --git a/flake.nix b/flake.nix index 238e7c0..00f6bdf 100644 --- a/flake.nix +++ b/flake.nix @@ -99,7 +99,7 @@ lastwave = pkgs.flutter.buildFlutterApplication { pname = "lastwave"; - version = "1.2.0"; + version = "1.3.0"; src = ./.; autoPubspecLock = ./pubspec.lock; diff --git a/lib/core/audio/stream_models.dart b/lib/core/audio/stream_models.dart index fdc83de..0fb84e1 100644 --- a/lib/core/audio/stream_models.dart +++ b/lib/core/audio/stream_models.dart @@ -116,6 +116,18 @@ class PlayableTrack { playbackUrl: playbackUrl ?? this.playbackUrl, playbackMimeType: playbackMimeType ?? this.playbackMimeType, ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlayableTrack && + runtimeType == other.runtimeType && + queueKey == other.queueKey && + videoId == other.videoId && + album == other.album; + + @override + int get hashCode => Object.hash(queueKey, videoId, album); } /// A resolved playable stream (lossless or YouTube). diff --git a/lib/features/addons/addon_api.dart b/lib/features/addons/addon_api.dart index 9b261fd..6404d9a 100644 --- a/lib/features/addons/addon_api.dart +++ b/lib/features/addons/addon_api.dart @@ -59,27 +59,57 @@ class AddonApi implements LosslessSource { String get _secret => AppEnv.addonClientSecret; @override - bool get isConfigured => - bases.isNotEmpty && _secret.isNotEmpty; + bool get isConfigured => bases.isNotEmpty; // -- URL parsing ------------------------------------------------------------ /// Accepts a full addon root (trailing slash optional), a manifest - /// URL, or a deeper addon URL (stream/…). Bare domains and - /// non-hex tokens are rejected (the server would 404 them anyway). + /// URL, or a deeper addon URL (stream/…). Supports tokenized addon + /// roots (`{base}/a//`) and standalone/worker addon roots + /// (`https://host/`). static ({String root, String token})? parseAddonUrl( String raw) { - var v = raw.trim().replaceAll(RegExp(r'/+$'), ''); + var v = raw.trim(); if (v.isEmpty) return null; - v = v.replaceAll(RegExp(r'/manifest(\.json)?$'), ''); + + final uri = Uri.tryParse(v); + if (uri == null || + (uri.scheme != 'http' && uri.scheme != 'https') || + uri.host.isEmpty) { + return null; + } + + v = v.replaceAll(RegExp(r'/+$'), ''); + v = v.replaceAll(RegExp(r'/manifest(\.json)?$', caseSensitive: false), ''); + final m = RegExp(r'^(https?://[^/?#]+)/a/([^/?#]+)').firstMatch(v); - if (m == null) return null; - final token = m.group(2)!; - if (!RegExp(r'^[0-9a-fA-F]{16,}$').hasMatch(token)) { + if (m != null) { + final token = m.group(2)!; + if (!RegExp(r'^[0-9a-fA-F]{16,}$').hasMatch(token)) { + return null; + } + return (root: '${m.group(1)}/a/$token/', token: token); + } + + // Malformed /a/ paths without a valid token are rejected. + if (RegExp(r'^(https?://[^/?#]+)/a(/.*)?$').hasMatch(v)) { return null; } - return (root: '${m.group(1)}/a/$token/', token: token); + + var cleanPath = uri.path.replaceAll(RegExp(r'/+$'), ''); + cleanPath = cleanPath.replaceAll( + RegExp(r'/manifest(\.json)?$', caseSensitive: false), ''); + cleanPath = cleanPath.replaceAll( + RegExp(r'/(stream|search)(/.*)?$', caseSensitive: false), ''); + + final origin = + '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; + final root = cleanPath.isEmpty || cleanPath == '/' + ? '$origin/' + : '$origin$cleanPath/'; + + return (root: root, token: ''); } // -- request signing (one-way lock) ------------------------------------------ @@ -112,13 +142,19 @@ class AddonApi implements LosslessSource { Map _signHeaders( String method, String root, String path) { + final token = tokenOfRoot(root); + if (_secret.isEmpty || token.isEmpty) { + return { + 'User-Agent': 'LastWave-Player/1.0', + }; + } final ts = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); return signFor( secret: _secret, method: method, path: path, - token: tokenOfRoot(root), + token: token, ts: ts); } diff --git a/lib/features/feed/feed_repository.dart b/lib/features/feed/feed_repository.dart index bc3793c..f2c70c3 100644 --- a/lib/features/feed/feed_repository.dart +++ b/lib/features/feed/feed_repository.dart @@ -155,7 +155,7 @@ String normalizeArtistKey(String artist) { // Parenthesised credits: "Artist (feat. X)", "[ft. X]", "(with X)" k = k .replaceAll( - RegExp(r'\s*[\(\[]\s*(feat\.?\vert{}ft\.?\vert{}featuring\vert{}with\vert{}prod\.?\vert{}produced by)\b[^\)\]]*[\)\]]'), '') + RegExp(r'\s*[\(\[]\s*(feat\.?|ft\.?|featuring|with|prod\.?|produced by)\b[^\)\]]*[\)\]]'), '') .trim(); // Trailing collab & featured credits: "A feat. B", "A ft B", "A featuring B", "A with B", "A prod. B" diff --git a/lib/features/innertube/innertube_api.dart b/lib/features/innertube/innertube_api.dart index 328199d..ad316c8 100644 --- a/lib/features/innertube/innertube_api.dart +++ b/lib/features/innertube/innertube_api.dart @@ -3260,6 +3260,100 @@ class InnerTubeMusicApi { return out.take(limit).toList(); } + /// Plain lyrics from a video's YouTube Music "Lyrics" tab, when it has one. + Future fetchLyricsTabText(String videoId) async { + final id = videoId.trim(); + if (id.isEmpty) return null; + await _ensureConfig(); + try { + final next = await _post( + '$musicApi/next?key=$_apiKey&prettyPrint=false', + body: { + 'context': _webContext(_clientVersion, _visitorData), + 'videoId': id, + 'isAudioOnly': true, + }, + clientName: 'WEB_REMIX', + clientVersion: _clientVersion, + userAgent: webUserAgent, + ); + final tabs = >[]; + _collectObjects(next, 'tabRenderer', tabs); + Map? lyricsTab; + for (final tab in tabs) { + final title = _runsText(tab['title']) ?? tab['title']?.toString() ?? ''; + if (title.toLowerCase().contains('lyrics')) { + lyricsTab = tab; + break; + } + } + dynamic endpoint = lyricsTab?['endpoint']?['browseEndpoint']; + if (endpoint == null && tabs.length > 1) { + endpoint = tabs[1]['endpoint']?['browseEndpoint']; + } + if (endpoint is! Map) return null; + final browseId = endpoint['browseId']?.toString(); + if (browseId == null || browseId.isEmpty) return null; + final params = endpoint['params']?.toString(); + + final page = await _post( + '$musicApi/browse?key=$_apiKey&prettyPrint=false', + body: { + 'context': _webContext(_clientVersion, _visitorData), + 'browseId': browseId, + if (params != null && params.isNotEmpty) 'params': params, + }, + clientName: 'WEB_REMIX', + clientVersion: _clientVersion, + userAgent: webUserAgent, + ); + final shelves = >[]; + _collectObjects(page, 'musicDescriptionShelfRenderer', shelves); + for (final shelf in shelves) { + final descObj = shelf['description']; + final desc = descObj is Map ? _runsText(descObj['runs']) : _runsText(descObj); + if (desc != null && desc.trim().isNotEmpty) return desc.trim(); + } + } catch (_) {} + return null; + } + + /// Timed transcript cues from a video, when it has them. + Future?> fetchTranscriptCues(String videoId) async { + final id = videoId.trim(); + if (id.isEmpty) return null; + await _ensureConfig(); + try { + final idBytes = utf8.encode(id); + final paramBytes = [0x0A, idBytes.length, ...idBytes]; + final params = base64.encode(paramBytes); + final res = await _post( + '$musicApi/get_transcript?key=$_apiKey&prettyPrint=false', + body: { + 'context': _webContext(_clientVersion, _visitorData), + 'params': params, + }, + clientName: 'WEB_REMIX', + clientVersion: _clientVersion, + userAgent: webUserAgent, + ); + final cues = >[]; + _collectObjects(res, 'transcriptCueRenderer', cues); + final lines = <({int timeMs, String text})>[]; + for (final cue in cues) { + final startOffset = int.tryParse(cue['startOffsetMs']?.toString() ?? ''); + if (startOffset == null) continue; + final cueObj = cue['cue']; + final text = (cueObj is Map ? _runsText(cueObj['runs']) : _runsText(cueObj)) ?? + cueObj?.toString() ?? ''; + if (text.trim().isEmpty) continue; + lines.add((timeMs: startOffset, text: text.trim())); + } + if (lines.isNotEmpty) return lines; + } catch (_) {} + return null; + } + /// Parse one `musicTwoRowItemRenderer`. /// /// [kind] defaults to [YouTubeEntityKind.album] because every caller diff --git a/lib/features/lyrics/karaoke_lyrics_view.dart b/lib/features/lyrics/karaoke_lyrics_view.dart index 9ae87c6..b603831 100644 --- a/lib/features/lyrics/karaoke_lyrics_view.dart +++ b/lib/features/lyrics/karaoke_lyrics_view.dart @@ -19,14 +19,13 @@ import '../../ui/theme/haze.dart'; import '../../ui/theme/tokens.dart'; import '../../ui/theme/wave_icons.dart'; import '../player/playback_service.dart'; -import 'flutter_lyric_adapter.dart'; import 'lyrics_models.dart'; import 'lyrics_providers.dart'; /// Provider for track-specific lyrics timing offset in milliseconds. final lyricsOffsetProvider = StateNotifierProvider.family( (ref, trackKey) { - final prefs = ref.watch(prefsProvider); + final prefs = ref.read(prefsProvider); return LyricsOffsetNotifier(prefs, trackKey); }, ); @@ -58,7 +57,7 @@ class LyricsOffsetNotifier extends StateNotifier { /// Provider for transliteration display toggle. final lyricsTransliterationProvider = StateNotifierProvider((ref) { - final prefs = ref.watch(prefsProvider); + final prefs = ref.read(prefsProvider); return LyricsTransliterationNotifier(prefs); }); @@ -139,11 +138,6 @@ class _WaveKaraokeLyricsViewState extends ConsumerState LyricsResult? _currentResult; bool _lastTransliteration = true; bool _lastWordByWord = true; - LyricStyle? _cachedStyle; - bool? _lastCompact; - double? _lastFontSize; - bool? _lastIsDark; - bool? _lastIsRtl; @override void initState() { @@ -199,8 +193,52 @@ class _WaveKaraokeLyricsViewState extends ConsumerState return routeCurrent && tickerEnabled; } + int _lastTickMs = 0; + + int _getCurrentPositionMs() { + if (_ticker.isActive) { + return _interpolatedPositionMs.value; + } + final snap = ref.read(playbackServiceProvider); + return snap.position.inMilliseconds - _offsetMs; + } + + bool _shouldTickerRun() { + if (!_isEffectivelyActive || !_isPlaying) return false; + final res = _currentResult; + if (res == null || !res.isSynced || res.lines.isEmpty || res.isInstrumental) { + return false; + } + if (!_lastWordByWord && !res.isWordSynced) { + return false; + } + final pos = _getCurrentPositionMs(); + final firstStart = res.lines.first.timeMs; + // Before song intro starts: + if (pos < firstStart - 600) return false; + + // After last line ends: + final lastLine = res.lines.last; + final lastEnd = lastLine.timeMs + math.max(lastLine.durationMs, 3000); + if (pos > lastEnd + 1000) return false; + + // In an instrumental break between lines: + final activeIdx = _activeLineIndex.value; + if (activeIdx >= 0 && activeIdx < res.lines.length - 1) { + final curLine = res.lines[activeIdx]; + final curEnd = curLine.timeMs + math.max(curLine.durationMs, 1000); + final nextStart = res.lines[activeIdx + 1].timeMs; + if (nextStart - curEnd > 1800) { + if (pos > curEnd + 300 && pos < nextStart - 600) { + return false; + } + } + } + return true; + } + void _updateTickerState() { - final shouldRun = _isEffectivelyActive && _isPlaying; + final shouldRun = _shouldTickerRun(); if (shouldRun && !_ticker.isActive) { _ticker.start(); } else if (!shouldRun && _ticker.isActive) { @@ -240,8 +278,15 @@ class _WaveKaraokeLyricsViewState extends ConsumerState void _onTick(Duration _) { runGuarded('karaoke.tick', () { if (!_isEffectivelyActive || !_isPlaying) return; - final wall = DateTime.now(); - final elapsed = wall.difference(_lastSyncTime).inMilliseconds; + final now = DateTime.now(); + final nowMs = now.millisecondsSinceEpoch; + // Cap tick rate to ~60Hz (min 16ms between ticks). + // On 120Hz/144Hz/240Hz monitors, this cuts CPU usage drastically + // while remaining completely smooth for text wipe animation. + if (nowMs - _lastTickMs < 16) return; + _lastTickMs = nowMs; + + final elapsed = now.difference(_lastSyncTime).inMilliseconds; final currentMs = _lastAudioMs + (elapsed * _speed).round() - _offsetMs; _interpolatedPositionMs.value = currentMs; @@ -251,6 +296,11 @@ class _WaveKaraokeLyricsViewState extends ConsumerState _activeLineIndex.value = lineIndex; } } + + // Check if we entered an idle gap or reached the end + if (!_shouldTickerRun()) { + _ticker.stop(); + } }); } @@ -263,9 +313,11 @@ class _WaveKaraokeLyricsViewState extends ConsumerState final audioMs = snap.position.inMilliseconds; _isPlaying = snap.isPlaying; _speed = snap.speed <= 0 ? 1.0 : snap.speed; - if (!_isPlaying) { + final now = DateTime.now(); + + if (!_isPlaying || !_ticker.isActive) { _lastAudioMs = audioMs; - _lastSyncTime = DateTime.now(); + _lastSyncTime = now; final effectiveMs = audioMs - _offsetMs; _interpolatedPositionMs.value = effectiveMs; if (_currentResult != null && _currentResult!.lines.isNotEmpty) { @@ -275,12 +327,12 @@ class _WaveKaraokeLyricsViewState extends ConsumerState } } } else if (audioMs != _lastAudioMs) { - final elapsed = DateTime.now().difference(_lastSyncTime).inMilliseconds; + final elapsed = now.difference(_lastSyncTime).inMilliseconds; final predicted = _lastAudioMs + (elapsed * _speed).round(); final drift = audioMs - predicted; if (drift < -80 || drift > 80) { _lastAudioMs = audioMs; - _lastSyncTime = DateTime.now(); + _lastSyncTime = now; final effectiveMs = audioMs - _offsetMs; _interpolatedPositionMs.value = effectiveMs; if (_currentResult != null && _currentResult!.lines.isNotEmpty) { @@ -307,7 +359,7 @@ class _WaveKaraokeLyricsViewState extends ConsumerState // not rebuild the karaoke view (it recreates the lyric adapter). final wordByWord = ref.watch(prefsProvider.select((p) => p.wordByWord)); - final async = ref.watch(waveLyricsProvider(widget.track.queueKey)); + final async = ref.watch(waveLyricsProvider(widget.track)); _offsetMs = offsetMs; // Covers offset/track/data-driven syncs; clock ticks arrive via the @@ -325,7 +377,7 @@ class _WaveKaraokeLyricsViewState extends ConsumerState title: 'Lyrics unavailable', message: 'Check your connection and try again.', onRetry: () => - ref.invalidate(waveLyricsProvider(widget.track.queueKey)), + ref.invalidate(waveLyricsProvider(widget.track)), ), ), data: (result) { @@ -378,7 +430,7 @@ class _WaveKaraokeLyricsViewState extends ConsumerState subtitle: 'Pick another source, or check back later.', actionLabel: 'Try again', onAction: () => - ref.invalidate(waveLyricsProvider(widget.track.queueKey)), + ref.invalidate(waveLyricsProvider(widget.track)), )); } if (!result.isSynced && result.lines.length < 2) { @@ -390,7 +442,6 @@ class _WaveKaraokeLyricsViewState extends ConsumerState ); } - final isRtl = result.lines.any((l) => l.isRtl); if (_currentTrackKey != widget.track.queueKey || _currentResult != result || @@ -409,37 +460,6 @@ class _WaveKaraokeLyricsViewState extends ConsumerState } else { _activeLineIndex.value = -1; } - final useFlutterLyric = wordByWord && result.isSynced; - if (useFlutterLyric) { - final model = convertToFlutterLyricModel( - result, - showTransliteration: showTransliteration, - wordByWord: result.isWordSynced, - ); - _lyricController.loadLyricModel(model); - final effectiveMs = _interpolatedPositionMs.value; - _lyricController.setProgress( - Duration(milliseconds: math.max(0, effectiveMs))); - } - } - - final isDark = waveIsDark(context); - if (_cachedStyle == null || - _lastCompact != widget.compact || - _lastFontSize != widget.fontSize || - _lastIsDark != isDark || - _lastIsRtl != isRtl) { - _lastCompact = widget.compact; - _lastFontSize = widget.fontSize; - _lastIsDark = isDark; - _lastIsRtl = isRtl; - _cachedStyle = buildAppleMusicLyricStyle( - context, - compact: widget.compact, - fontSize: widget.fontSize, - isDark: isDark, - isRtl: isRtl, - ); } return RepaintBoundary( @@ -607,6 +627,10 @@ class _WaveKaraokeLyricLineState extends State { bool _isHovered = false; int _lastPos = 0; int _seekGen = 0; + LyricLine? _cachedLine; + bool? _cachedUseKaraokeWipe; + List _cachedSyllables = const []; + List _cachedWords = const []; @override void initState() { @@ -623,6 +647,11 @@ class _WaveKaraokeLyricLineState extends State { _lastPos = widget.positionListenable?.value ?? 0; widget.positionListenable?.addListener(_onLineTick); } + if (oldWidget.line != widget.line || + oldWidget.karaoke != widget.karaoke || + oldWidget.isActive != widget.isActive) { + _cachedLine = null; + } } @override @@ -697,26 +726,30 @@ class _WaveKaraokeLyricLineState extends State { Widget content; final useKaraokeWipe = widget.karaoke && widget.isActive; - final effectiveSyllables = useKaraokeWipe - ? (widget.line.hasSyllables - ? widget.line.syllables - : interpolateLineSyllables( - text: widget.line.text, - startTimeMs: widget.line.timeMs, - durationMs: widget.line.durationMs, - )) - : const []; - - if (effectiveSyllables.isNotEmpty) { - final words = - groupSyllablesIntoWords(effectiveSyllables, widget.line.text); + if (_cachedLine != widget.line || _cachedUseKaraokeWipe != useKaraokeWipe) { + _cachedLine = widget.line; + _cachedUseKaraokeWipe = useKaraokeWipe; + _cachedSyllables = useKaraokeWipe + ? (widget.line.hasSyllables + ? widget.line.syllables + : interpolateLineSyllables( + text: widget.line.text, + startTimeMs: widget.line.timeMs, + durationMs: widget.line.durationMs, + )) + : const []; + _cachedWords = _cachedSyllables.isNotEmpty + ? groupSyllablesIntoWords(_cachedSyllables, widget.line.text) + : const []; + } + if (_cachedWords.isNotEmpty) { content = Wrap( textDirection: widget.line.isRtl ? TextDirection.rtl : TextDirection.ltr, crossAxisAlignment: WrapCrossAlignment.center, children: [ - for (final word in words) + for (final word in _cachedWords) Row( mainAxisSize: MainAxisSize.min, textDirection: @@ -807,8 +840,10 @@ class _WaveKaraokeLyricLineState extends State { } } +enum _SyllableState { idle, singing, sung } + /// Bands used to feather the karaoke wipe edge (alpha steps of 1/N). -const _wipeFeatherBands = 5; +const _wipeFeatherBands = 2; /// Progressive fill / wipe for an individual syllable with soft anti-aliased edge. class _KaraokeSyllableWidget extends StatefulWidget { @@ -836,13 +871,19 @@ class _KaraokeSyllableWidget extends StatefulWidget { } class _KaraokeSyllableWidgetState extends State<_KaraokeSyllableWidget> { - late double _progress; + _SyllableState _state = _SyllableState.idle; bool _isListening = false; int get _startMs => widget.syllable.timeMs; int get _endMs => widget.syllable.timeMs + math.max(1, widget.syllable.durationMs); + _SyllableState _stateFor(int pos) { + if (pos >= _endMs) return _SyllableState.sung; + if (pos >= _startMs) return _SyllableState.singing; + return _SyllableState.idle; + } + void _startListening() { if (_isListening || widget.positionListenable == null) return; widget.positionListenable!.addListener(_onPositionTick); @@ -859,11 +900,8 @@ class _KaraokeSyllableWidgetState extends State<_KaraokeSyllableWidget> { void initState() { super.initState(); final pos = widget.positionListenable?.value ?? widget.initialPosMs; - _progress = calculateSyllableProgress(widget.syllable, pos); - // If initialPosMs >= endMs: The syllable is already sung. - // Do NOT call widget.positionListenable.addListener(_onPositionTick). - // Render a plain Text(widget.syllable.text, style: activeStyle) with zero ShaderMask. - if (pos < _endMs) { + _state = _stateFor(pos); + if (_state != _SyllableState.sung) { _startListening(); } } @@ -878,8 +916,11 @@ class _KaraokeSyllableWidgetState extends State<_KaraokeSyllableWidget> { } } final pos = widget.positionListenable?.value ?? widget.initialPosMs; - _progress = calculateSyllableProgress(widget.syllable, pos); - if (pos < _endMs) { + final newState = _stateFor(pos); + if (newState != _state) { + _state = newState; + } + if (_state != _SyllableState.sung) { _startListening(); } else { _stopListening(); @@ -926,7 +967,6 @@ class _KaraokeSyllableWidgetState extends State<_KaraokeSyllableWidget> { p.dispose(); } _featherPainters = [ - // cover 1 - 1/N down to 1/N; the final band (cover 0) is the bare base. for (var i = 1; i < _wipeFeatherBands; i++) TextPainter( text: TextSpan( @@ -959,9 +999,6 @@ class _KaraokeSyllableWidgetState extends State<_KaraokeSyllableWidget> { )..layout(); } - // Separate text cache keys: the idle branch used to consume - // `_cachedText` first, so a text change skipped the active/feather - // rebuild below (stale glyphs in the wipe layer). if (_activePainter == null || _cachedActiveText != text || _cachedActiveStyle != widget.activeStyle || @@ -994,33 +1031,17 @@ class _KaraokeSyllableWidgetState extends State<_KaraokeSyllableWidget> { final listenable = widget.positionListenable; if (listenable == null) return; final pos = listenable.value; - final startMs = _startMs; - final endMs = _endMs; - - // While pos < startMs: Keep _progress = 0.0 and do NOT call setState() on intermediate ticks - if (pos < startMs) { - if (_progress != 0.0) { - setState(() => _progress = 0.0); - } - return; - } - - // When pos >= endMs: Immediately call removeListener inside _onPositionTick, - // set _progress = 1.0, call setState once, and permanently go dormant. - if (pos >= endMs) { - _stopListening(); - if (_progress != 1.0) { - setState(() => _progress = 1.0); + final newState = _stateFor(pos); + + // Only rebuild on state transitions (idle -> singing, singing -> sung). + // During active wipe (singing), the CustomPainter repaints directly via + // Listenable with zero widget rebuild overhead. + if (newState != _state) { + if (newState == _SyllableState.sung) { + _stopListening(); } - return; - } - - // Only when startMs <= pos < endMs (0.0 < _progress < 1.0) should setState() fire at 144Hz - final duration = math.max(1, widget.syllable.durationMs); - final newProgress = ((pos - startMs) / duration).clamp(0.0, 1.0); - if ((_progress - newProgress).abs() > 0.001) { setState(() { - _progress = newProgress; + _state = newState; }); } } @@ -1031,16 +1052,16 @@ class _KaraokeSyllableWidgetState extends State<_KaraokeSyllableWidget> { if (text.isEmpty) return const SizedBox.shrink(); // Fast-path 1: Completed syllable -> Plain Text with active style (zero canvas overhead) - if (_progress >= 1.0) { + if (_state == _SyllableState.sung) { return Text(text, style: widget.activeStyle); } // Fast-path 2: Future syllable -> Plain Text with idle style (zero canvas overhead) - if (_progress <= 0.0) { + if (_state == _SyllableState.idle) { return Text(text, style: widget.idleStyle); } - // Active syllable (0.0 < progress < 1.0): Zero-allocation direct canvas clip (eliminates ShaderMask & saveLayer) + // Active syllable: Zero-allocation direct canvas clip driven directly by positionListenable _ensurePainters(context); final size = Size(_idlePainter!.width, _idlePainter!.height); @@ -1051,10 +1072,12 @@ class _KaraokeSyllableWidgetState extends State<_KaraokeSyllableWidget> { child: CustomPaint( size: size, painter: _KaraokeSyllablePainter( + positionListenable: widget.positionListenable, + syllable: widget.syllable, + initialPosMs: widget.initialPosMs, idlePainter: _idlePainter!, activePainter: _activePainter!, featherPainters: _featherPainters, - progress: _progress, isRtl: widget.isRtl, ), ), @@ -1065,28 +1088,32 @@ class _KaraokeSyllableWidgetState extends State<_KaraokeSyllableWidget> { /// Zero-allocation canvas clip painter for syllable wipe (replaces ShaderMask / saveLayer). /// -/// The leading edge is feathered with a staircase of alpha-stepped copies of -/// the active text: `Canvas.drawParagraph` takes no paint, so paragraph alpha -/// only exists in the style — saveLayer/ShaderMask would reintroduce the -/// offscreen cost this painter exists to avoid. Bands are cached by the -/// syllable widget and disposed with it. +/// Driven directly by [positionListenable] to isolate repaints to the active syllable +/// without scheduling widget rebuilds or thrashing the element tree. class _KaraokeSyllablePainter extends CustomPainter { + final ValueListenable? positionListenable; + final LyricSyllable syllable; + final int initialPosMs; final TextPainter idlePainter; final TextPainter activePainter; final List featherPainters; - final double progress; final bool isRtl; - const _KaraokeSyllablePainter({ + _KaraokeSyllablePainter({ + required this.positionListenable, + required this.syllable, + required this.initialPosMs, required this.idlePainter, required this.activePainter, required this.featherPainters, - required this.progress, required this.isRtl, - }); + }) : super(repaint: positionListenable); @override void paint(Canvas canvas, Size size) { + final pos = positionListenable?.value ?? initialPosMs; + final progress = calculateSyllableProgress(syllable, pos); + // 1. Draw base idle text idlePainter.paint(canvas, Offset.zero); @@ -1094,7 +1121,6 @@ class _KaraokeSyllablePainter extends CustomPainter { // 2. Wipe: hard-clipped active text, then alpha-stepped bands feathering // the leading edge so the fill does not cut a hard line across glyphs. - // `s` is distance travelled along the wipe direction (0 = start edge). final width = size.width; final front = width * progress.clamp(0.0, 1.0); final feather = math.min(width * 0.08, front * 0.8).clamp(0.0, 18.0); @@ -1131,7 +1157,8 @@ class _KaraokeSyllablePainter extends CustomPainter { @override bool shouldRepaint(_KaraokeSyllablePainter oldDelegate) { - return oldDelegate.progress != progress || + return oldDelegate.positionListenable != positionListenable || + oldDelegate.syllable != syllable || oldDelegate.idlePainter != idlePainter || oldDelegate.activePainter != activePainter || oldDelegate.featherPainters != featherPainters || @@ -1180,7 +1207,7 @@ class _SourceMenu extends ConsumerWidget { ref.read(lyricsExcludedProvidersProvider(queueKey).notifier).state = next.excludes; } - ref.invalidate(waveLyricsProvider(queueKey)); + ref.invalidate(waveLyricsProvider(track)); } return LWTooltip( diff --git a/lib/features/lyrics/lyrics_models.dart b/lib/features/lyrics/lyrics_models.dart index 8b7a769..f384482 100644 --- a/lib/features/lyrics/lyrics_models.dart +++ b/lib/features/lyrics/lyrics_models.dart @@ -1,5 +1,9 @@ import 'dart:math' as math; +import 'package:unorm_dart/unorm_dart.dart' as unorm; + +final _diacriticsRegex = RegExp(r'\p{M}+', unicode: true); + /// Lyrics models. Ported from LastWave-native /// `data/lyrics/LyricsRepository.kt`. class LyricSyllable { @@ -115,11 +119,7 @@ int parseLyricTimestampMs(dynamic raw) { /// scroll must not treat that as "already on the last line". bool lyricsAreUntimed(List lines) { if (lines.length < 2) return false; - final first = lines.first.timeMs; - for (var i = 1; i < lines.length; i++) { - if (lines[i].timeMs != first) return false; - } - return true; + return lines.first.timeMs == lines.last.timeMs; } int lyricsBodyLength(LyricsResult result) { @@ -175,26 +175,33 @@ int activeLyricLineIndex(List lines, int positionMs) { if (positionMs < lines.first.timeMs) return -1; if (lyricsAreUntimed(lines)) return -1; - var nextDistinct = -1; - for (var i = 1; i < lines.length; i++) { - if (lines[i].timeMs != lines.first.timeMs) { - nextDistinct = i; - break; + if (lines.length > 1 && lines[0].timeMs == lines[1].timeMs) { + var nextDistinctTime = -1; + for (var i = 2; i < lines.length; i++) { + if (lines[i].timeMs != lines[0].timeMs) { + nextDistinctTime = lines[i].timeMs; + break; + } + } + if (nextDistinctTime >= 0 && positionMs < nextDistinctTime) { + return 0; } - } - if (nextDistinct >= 0 && positionMs < lines[nextDistinct].timeMs) { - return 0; } - var active = 0; - for (var i = 0; i < lines.length; i++) { - if (lines[i].timeMs <= positionMs) { - active = i; + var low = 0; + var high = lines.length - 1; + var result = 0; + + while (low <= high) { + final mid = (low + high) >> 1; + if (lines[mid].timeMs <= positionMs) { + result = mid; + low = mid + 1; } else { - break; + high = mid - 1; } } - return active; + return result; } double calculateSyllableProgress(LyricSyllable syl, int posMs) { @@ -368,12 +375,18 @@ bool isRtlText(String text) { /// runs to the next stamp (or the next line, or +800ms), and stamps are /// stripped from the display text. Unstamped input parses exactly as before. List parseLrc(String lrc) { + var text = lrc; + if (text.contains(r'\n') && !text.contains('\n')) { + text = text.replaceAll(r'\r\n', '\n').replaceAll(r'\n', '\n'); + } + text = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + final timestampRegex = - RegExp(r'\[(\d{1,2}):(\d{2})(?:[.:](\d{2,3}))?\]'); - final offsetRegex = RegExp(r'\[offset:\s*([+-]?\d+)\]'); + RegExp(r'\[\s*(?:(\d{1,2}):)?(\d{1,3}):(\d{2})(?:[.:](\d{1,4}))?\s*\]'); + final offsetRegex = RegExp(r'\[offset:\s*([+-]?\d+)\]', caseSensitive: false); var offset = 0; final rawEntries = <({int timeMs, String body})>[]; - for (final raw in lrc.split('\n')) { + for (final raw in text.split('\n')) { final line = raw.trim(); if (line.isEmpty) continue; final offsetMatch = offsetRegex.firstMatch(line); @@ -385,9 +398,7 @@ List parseLrc(String lrc) { if (matches.isEmpty) continue; final body = line.replaceAll(timestampRegex, ''); for (final m in matches) { - final min = int.tryParse(m.group(1) ?? '') ?? 0; - final sec = int.tryParse(m.group(2) ?? '') ?? 0; - final fracRaw = m.group(3) ?? ''; + final fracRaw = m.group(4) ?? ''; var fracMs = 0; if (fracRaw.length == 1) { fracMs = (int.tryParse(fracRaw) ?? 0) * 100; @@ -395,10 +406,37 @@ List parseLrc(String lrc) { fracMs = (int.tryParse(fracRaw) ?? 0) * 10; } else if (fracRaw.length == 3) { fracMs = int.tryParse(fracRaw) ?? 0; + } else if (fracRaw.length >= 4) { + fracMs = (int.tryParse(fracRaw.substring(0, 3)) ?? 0); + } + int totalMs; + if (m.group(1) != null && fracRaw.isNotEmpty) { + // 4 components: hh:mm:ss.frac + final hour = int.tryParse(m.group(1)!) ?? 0; + final min = int.tryParse(m.group(2) ?? '') ?? 0; + final sec = int.tryParse(m.group(3) ?? '') ?? 0; + totalMs = (hour * 3600000 + min * 60000 + sec * 1000 + fracMs + offset) + .clamp(0, 1 << 31); + } else if (m.group(1) != null && fracRaw.isEmpty) { + // 3 colon-separated components: [mm:ss:xx] (standard 3-field LRC where xx is hundredths/frames) + final min = int.tryParse(m.group(1)!) ?? 0; + final sec = int.tryParse(m.group(2) ?? '') ?? 0; + final rawFrac = m.group(3) ?? ''; + var fMs = int.tryParse(rawFrac) ?? 0; + if (rawFrac.length == 1) { + fMs *= 100; + } else if (rawFrac.length == 2) { + fMs *= 10; + } else if (rawFrac.length >= 4) { + fMs = int.tryParse(rawFrac.substring(0, 3)) ?? 0; + } + totalMs = (min * 60000 + sec * 1000 + fMs + offset).clamp(0, 1 << 31); + } else { + // 2 components: [mm:ss] or [mm:ss.frac] + final min = int.tryParse(m.group(2) ?? '') ?? 0; + final sec = int.tryParse(m.group(3) ?? '') ?? 0; + totalMs = (min * 60000 + sec * 1000 + fracMs + offset).clamp(0, 1 << 31); } - final totalMs = - (min * 60000 + sec * 1000 + fracMs + offset) - .clamp(0, 1 << 31); rawEntries.add((timeMs: totalMs, body: body)); } } @@ -519,8 +557,15 @@ const _versionKeywords = { 'session': 'live', 'unplugged': 'unplugged', 'acoustic': 'acoustic', + 'piano': 'acoustic', + 'stripped': 'acoustic', 'remix': 'remix', + 'mix': 'remix', + 'club mix': 'remix', + 'extended': 'remix', + 'vip': 'remix', 'cover': 'cover', + 'tribute': 'cover', 'karaoke': 'karaoke', 'instrumental': 'instrumental', 'slowed': 'slowed', @@ -532,9 +577,20 @@ const _versionKeywords = { 'demo': 'demo', 'lullaby': 'lullaby', '8d': '8d', + 'interlude': 'interlude', + 'intro': 'intro', + 'outro': 'outro', + 'skit': 'skit', + 'acapella': 'acapella', + 'a cappella': 'acapella', + 'orchestral': 'orchestral', + 'orchestra': 'orchestral', + 'symphonic': 'orchestral', + 're-recorded': 'rerecorded', + 'rerecorded': 'rerecorded', }; -/// Version tags found in brackets or a trailing `- X` suffix. +/// Version tags found in brackets, trailing `- X`, or trailing version word. Set _versionTags(String rawTitle) { final tags = {}; final segments = []; @@ -544,6 +600,13 @@ Set _versionTags(String rawTitle) { final trailing = RegExp(r'\s*[-–—:]\s*([^-–—:\(\[]+)\s*$').firstMatch(rawTitle); if (trailing != null) segments.add(trailing.group(1) ?? ''); + + final trailingWord = RegExp( + r'\b(remix|acoustic|live|cover|karaoke|instrumental|slowed|nightcore|extended|unplugged|vip)\s*$', + caseSensitive: false) + .firstMatch(rawTitle); + if (trailingWord != null) segments.add(trailingWord.group(1) ?? ''); + for (final segment in segments) { final lower = ' ${segment.toLowerCase()} '; _versionKeywords.forEach((keyword, tag) { @@ -571,56 +634,115 @@ String _stripLeadingArtistPrefix(String raw) { } String _normalizeLyricsSymbol(String s) { - var v = s.toLowerCase(); + var v = s + .replaceAll(RegExp(r"[\u2018\u2019\u201A\u201B`']"), '') + .replaceAll(RegExp(r'[\u201C\u201D\u201E\u201F"]'), ' ') + .replaceAll(RegExp(r'[\u2013\u2014\u2212-]'), ' '); + v = unorm.nfd(v.toLowerCase()); + v = v.replaceAll(_diacriticsRegex, ''); v = v.replaceAll(RegExp(r'\$(?=\d)'), ''); v = v.replaceAll(r'$', 's'); return v; } +Set _extractArtistEntities(String rawArtist) { + final stripped = rawArtist + .replaceAll(RegExp(r'\s*-\s*Topic$', caseSensitive: false), '') + .trim(); + final cleaned = stripped.replaceAll( + RegExp( + r'\s*[\(\[]\s*(?:feat\.?|ft\.?|featuring\b|with\b|prod\.?|produced by\b)[^\)\]]*[\)\]]', + caseSensitive: false), + '', + ); + final normalized = _normalizeLyricsSymbol(cleaned).trim(); + final entities = {}; + if (normalized.isNotEmpty) { + entities.add(normalized); + } + final parts = normalized.split( + RegExp(r'\s*[,/;&+]\s*|\s+(?:feat\.?|ft\.?|featuring|with|vs\.?|x)\s+', + caseSensitive: false), + ); + for (final p in parts) { + final t = p.trim(); + if (t.isNotEmpty) entities.add(t); + } + return entities; +} + bool _tokenOverlap(String a, String b, double floor) { - final aTokens = _normalizeLyricsSymbol(a).split(RegExp(r'\s+')).toSet(); - final bTokens = _normalizeLyricsSymbol(b).split(RegExp(r'\s+')).toSet(); + final aTokens = _normalizeLyricsSymbol(a).split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toSet(); + final bTokens = _normalizeLyricsSymbol(b).split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toSet(); final union = aTokens.union(bTokens).length; if (union == 0) return false; return aTokens.intersection(bTokens).length / union >= floor; } /// Title match that also tolerates dirty community titles carrying an -/// `Artist - Title` prefix. Bare `contains` is length-gated (shorter -/// side must cover >=70% of the longer side), else token overlap decides. +/// `Artist - Title` prefix. Enforces version agreement, length gate, +/// and high token overlap. bool lyricsTitlesMatchStrict(String candidateTitle, String requestTitle) { - for (final cand in [candidateTitle, _stripLeadingArtistPrefix(candidateTitle)]) { - final ca = _normalizeLyricsSymbol(cand).trim(); - final cb = _normalizeLyricsSymbol(requestTitle).trim(); - if (ca.isEmpty || cb.isEmpty) continue; - if (ca == cb) return true; - if (ca.contains(cb) || cb.contains(ca)) { - final ratio = - math.min(ca.length, cb.length) / math.max(ca.length, cb.length); - if (ratio >= 0.7) return true; + if (!lyricsSameVersion(requestTitle, candidateTitle)) return false; + + final cands = [candidateTitle, _stripLeadingArtistPrefix(candidateTitle)]; + final reqs = [requestTitle, _stripLeadingArtistPrefix(requestTitle)]; + + for (final cand in cands) { + for (final req in reqs) { + final ca = _normalizeLyricsSymbol(cand).trim(); + final cb = _normalizeLyricsSymbol(req).trim(); + if (ca.isEmpty || cb.isEmpty) continue; + if (ca == cb) return true; + if (ca.contains(cb) || cb.contains(ca)) { + final ratio = + math.min(ca.length, cb.length) / math.max(ca.length, cb.length); + if (ratio >= 0.7) return true; + } + if (_tokenOverlap(ca, cb, 0.6)) return true; } - if (_tokenOverlap(ca, cb, 0.5)) return true; } return false; } -/// Artist agreement for search-result filtering. Bare `contains` both -/// ways accepted `Ann` for `Annie` — exact wins, substring needs length -/// cover, otherwise token overlap. +/// Artist agreement for search-result filtering. Delimiter-aware entity +/// matching handles multi-artist billings (commas, feat, &, x) while +/// rejecting short-name theft. bool lyricsArtistsMatchStrict(String candidateArtist, String requestArtist) { final ca = _normalizeLyricsSymbol(candidateArtist).trim(); final ra = _normalizeLyricsSymbol(requestArtist).trim(); if (ca.isEmpty || ra.isEmpty) return false; if (ca == ra) return true; + + final cEntities = _extractArtistEntities(candidateArtist); + final rEntities = _extractArtistEntities(requestArtist); + + for (final ce in cEntities) { + if (ce.length < 2) continue; + for (final re in rEntities) { + if (re.length < 2) continue; + if (ce == re) return true; + } + } + + final shorter = ca.length <= ra.length ? ca : ra; + final longer = ca.length <= ra.length ? ra : ca; if (ca.contains(ra) || ra.contains(ca)) { - if (math.min(ca.length, ra.length) < 4) { - return _tokenOverlap(ca, ra, 0.5); + if (shorter.length < 4) { + return _tokenOverlap(ca, ra, 0.6); + } + final pattern = + r'(^|(?:\s*[,&/;+]\s*|\s+(?:with|feat\.?|ft\.?|featuring|vs\.?|x)\s+))' + + RegExp.escape(shorter) + + r'($|(?:\s*[,&/;+]\s*|\s+(?:with|feat\.?|ft\.?|featuring|vs\.?|x)\s+))'; + if (RegExp(pattern, caseSensitive: false).hasMatch(longer)) { + return true; } final ratio = math.min(ca.length, ra.length) / math.max(ca.length, ra.length); if (ratio >= 0.6) return true; } - return _tokenOverlap(ca, ra, 0.5); + return _tokenOverlap(ca, ra, 0.6); } final _searchWhitespace = RegExp(r'\s+'); @@ -704,26 +826,32 @@ bool lyricsPlausibleDuration(List lines, int? durationSeconds) { } if (endMs <= 0) return true; if (endMs > expectedMs + 45000) return false; - if (endMs < expectedMs * 0.5 && expectedMs - endMs > 90000) return false; + if (endMs < expectedMs * 0.45 && expectedMs - endMs > 90000) return false; return true; } /// Parse TTML (`

` lines, `` words). List parseTtml(String ttml) { + var raw = ttml; + if (raw.contains(r'\n') && !raw.contains('\n')) { + raw = raw.replaceAll(r'\r\n', '\n').replaceAll(r'\n', '\n'); + } final pTag = RegExp( - r']*?)>(.*?)

', - dotAll: true); + r'<(?:[a-zA-Z0-9_-]+:)?p\b([^>]*?)>(.*?)', + dotAll: true, + caseSensitive: false); final spanTag = RegExp( - r']*?)>(.*?)(\s*)', - dotAll: true); + r'<(?:[a-zA-Z0-9_-]+:)?span\b([^>]*?)>(.*?)(\s*)', + dotAll: true, + caseSensitive: false); final xmlTag = RegExp(r'<[^>]+>'); final lines = []; - for (final p in pTag.allMatches(ttml)) { + for (final p in pTag.allMatches(raw)) { final pAttrs = p.group(1) ?? ''; final inner = p.group(2) ?? ''; - final pBeginMatch = RegExp(r'begin="([^"]+)"').firstMatch(pAttrs); - final pEndMatch = RegExp(r'end="([^"]+)"').firstMatch(pAttrs); + final pBeginMatch = RegExp(r'''(?:begin|start|startTime|from)=["']([^"']+)["']''', caseSensitive: false).firstMatch(pAttrs); + final pEndMatch = RegExp(r'''(?:end|endTime|to)=["']([^"']+)["']''', caseSensitive: false).firstMatch(pAttrs); final start = _parseTtmlTime(pBeginMatch?.group(1) ?? ''); final end = _parseTtmlTime(pEndMatch?.group(1) ?? ''); final duration = (end - start).clamp(0, 1 << 31); @@ -732,16 +860,13 @@ List parseTtml(String ttml) { if (spans.isEmpty) { final text = _unescapeXml(inner.replaceAll(xmlTag, '').trim()); if (text.isEmpty) continue; - final syllables = interpolateLineSyllables( - text: text, - startTimeMs: start, - durationMs: duration > 0 ? duration : 4000, - ); + // Line-synced TTML carries no syllables so that wordByWord racing + // and providers correctly distinguish line sync vs word sync. lines.add(LyricLine( timeMs: start, - durationMs: duration, + durationMs: duration > 0 ? duration : 4000, text: text, - syllables: syllables, + syllables: const [], )); } else { final syllables = []; @@ -756,8 +881,8 @@ List parseTtml(String ttml) { final rawContent = s.group(2) ?? ''; final trailingSpace = s.group(3) ?? ''; - final beginMatch = RegExp(r'begin="([^"]+)"').firstMatch(attrs); - final endMatch = RegExp(r'end="([^"]+)"').firstMatch(attrs); + final beginMatch = RegExp(r'''(?:begin|start|startTime|from)=["']([^"']+)["']''', caseSensitive: false).firstMatch(attrs); + final endMatch = RegExp(r'''(?:end|endTime|to)=["']([^"']+)["']''', caseSensitive: false).firstMatch(attrs); if (beginMatch == null || endMatch == null) { // Nested or container span (e.g. ) @@ -767,8 +892,8 @@ List parseTtml(String ttml) { final ispan = innerSpans[j]; final isInnerLast = isLast && j == innerSpans.length - 1; final iattrs = ispan.group(1) ?? ''; - final ibMatch = RegExp(r'begin="([^"]+)"').firstMatch(iattrs); - final ieMatch = RegExp(r'end="([^"]+)"').firstMatch(iattrs); + final ibMatch = RegExp(r'''(?:begin|start|startTime|from)=["']([^"']+)["']''', caseSensitive: false).firstMatch(iattrs); + final ieMatch = RegExp(r'''(?:end|endTime|to)=["']([^"']+)["']''', caseSensitive: false).firstMatch(iattrs); if (ibMatch == null || ieMatch == null) continue; final ws = _parseTtmlTime(ibMatch.group(1) ?? ''); final we = _parseTtmlTime(ieMatch.group(1) ?? ''); @@ -821,17 +946,20 @@ List parseTtml(String ttml) { } int _parseTtmlTime(String s) { - s = s.trim(); - if (s.isEmpty) return 0; + var str = s.trim(); + if (str.isEmpty) return 0; try { - if (s.endsWith('ms')) { - return double.parse(s.substring(0, s.length - 2)).round(); - } - if (s.endsWith('s')) { - return (double.parse(s.substring(0, s.length - 1)) * 1000).round(); + if (str.endsWith('ms')) { + final val = str.substring(0, str.length - 2).trim(); + if (!val.contains(':')) { + return double.parse(val).round(); + } + str = val; + } else if (str.endsWith('s')) { + str = str.substring(0, str.length - 1).trim(); } - if (s.contains(':')) { - final parts = s.split(':'); + if (str.contains(':')) { + final parts = str.split(':'); final nums = parts.map(double.parse).toList().reversed.toList(); var total = 0.0; var mult = 1.0; @@ -841,7 +969,7 @@ int _parseTtmlTime(String s) { } return (total * 1000).round(); } - return (double.parse(s) * 1000).round(); + return (double.parse(str) * 1000).round(); } catch (_) { return 0; } diff --git a/lib/features/lyrics/lyrics_providers.dart b/lib/features/lyrics/lyrics_providers.dart index 758fc21..c985b2b 100644 --- a/lib/features/lyrics/lyrics_providers.dart +++ b/lib/features/lyrics/lyrics_providers.dart @@ -21,10 +21,15 @@ enum LyricsProviderId { 'Auto', 'Fastest word-sync wins, LRCLIB fallback', ), + lastwave( + 'lastwave', + 'LastWave', + 'Syllable & line-synced Apple lyrics first', + ), lrcRed( 'lrc_red', - 'Lrc.Red', - 'Recording-matched word-sync first', + 'lrc.red', + 'ISRC-indexed syllable TTML & LRC first', ), appleMusic( 'apple_music', @@ -36,11 +41,21 @@ enum LyricsProviderId { 'BetterLyrics', 'Word-synced lyrics first', ), + betterLyricsPortato( + 'better_lyrics_portato', + 'QQ Karaoke', + 'QQ Music karaoke word timings first', + ), kugou( 'kugou', 'Kugou', 'KRC word-synced lyrics first', ), + biniLyrics( + 'bini_lyrics', + 'Syllable-Sync', + 'Recording-matched syllable lyrics first', + ), simpMusic( 'simp_music', 'Video-Match', @@ -55,6 +70,36 @@ enum LyricsProviderId { 'lrclib', 'LRCLIB', 'Line-synced community lyrics first', + ), + lyricsPlus( + 'lyrics_plus', + 'LyricsPlus', + 'Syllable-synced on community mirrors first', + ), + unison( + 'unison', + 'Unison', + 'Community-submitted synced lyrics first', + ), + youtubeCaptions( + 'youtube_captions', + 'YouTube Captions', + 'Timed captions from the playing video first', + ), + youtubeMusic( + 'youtube_music', + 'YouTube Music', + 'Plain lyrics from the video\'s Lyrics tab first', + ), + megalobiz( + 'megalobiz', + 'Megalobiz', + 'Community line-synced LRC first', + ), + genius( + 'genius', + 'Genius', + 'Plain-text web catalogue, tried last', ); final String id; @@ -65,9 +110,17 @@ enum LyricsProviderId { /// True for providers that can return syllable/word timing and take /// part in the word-sync race with a preferred head start. - bool get isWordProvider => - this != auto && - this != lrclib; + bool get isWordProvider => switch (this) { + auto || + lrclib || + musixmatch || + megalobiz || + genius || + youtubeMusic || + youtubeCaptions => + false, + _ => true, + }; static LyricsProviderId fromId(String? id) => values.firstWhere( @@ -80,13 +133,36 @@ enum LyricsProviderId { /// yield null (no exclusion possible for them). String? lyricsSourceToProviderId(String source) { final s = source.toLowerCase(); + if (s.startsWith('lastwave')) return LyricsProviderId.lastwave.id; if (s.startsWith('lrc.red')) return LyricsProviderId.lrcRed.id; - if (s.contains('apple')) return LyricsProviderId.appleMusic.id; + if (s.startsWith('apple music') || s.contains('apple')) { + return LyricsProviderId.appleMusic.id; + } + if (s.startsWith('qq karaoke') || s.startsWith('portato')) { + return LyricsProviderId.betterLyricsPortato.id; + } if (s.startsWith('betterlyrics')) return LyricsProviderId.betterLyrics.id; if (s.startsWith('kugou')) return LyricsProviderId.kugou.id; - if (s.startsWith('video-match')) return LyricsProviderId.simpMusic.id; - if (s.startsWith('catalog')) return LyricsProviderId.musixmatch.id; + if (s.startsWith('syllable-sync') || s.startsWith('bini')) { + return LyricsProviderId.biniLyrics.id; + } + if (s.startsWith('video-match') || s.startsWith('simp')) { + return LyricsProviderId.simpMusic.id; + } + if (s.startsWith('catalog') || s.startsWith('musixmatch')) { + return LyricsProviderId.musixmatch.id; + } if (s.contains('lrclib')) return LyricsProviderId.lrclib.id; + if (s.startsWith('lyricsplus')) return LyricsProviderId.lyricsPlus.id; + if (s.startsWith('unison')) return LyricsProviderId.unison.id; + if (s.startsWith('youtube captions')) { + return LyricsProviderId.youtubeCaptions.id; + } + if (s.startsWith('youtube music')) { + return LyricsProviderId.youtubeMusic.id; + } + if (s.startsWith('megalobiz')) return LyricsProviderId.megalobiz.id; + if (s.startsWith('genius')) return LyricsProviderId.genius.id; return null; } @@ -233,10 +309,12 @@ Future<({List lines, bool wordSynced})?> _fetchBetterAttempt( /// display later, which must never count as word-sync. bool _betterHasWordTiming(String payload) { final lower = payload.toLowerCase(); - if (lower.contains(']*\bbegin\s*=', caseSensitive: false) - .hasMatch(payload); + if (lower.contains(' l.hasSyllables); } } if (parseBetterKaraoke(payload).isNotEmpty) return true; @@ -250,7 +328,9 @@ List? parseBetterDocument(String raw) { final payload = _unwrapBetterPayload(raw); if (payload == null || payload.isEmpty) return null; final lower = payload.toLowerCase(); - if (lower.contains(' lines, bool wordSynced})?> fetchSimpMusic( return null; } } + +// -- QQ Karaoke / Portato (lyrics-api.boidu.dev/qq/getLyrics) ----------------- +Future fetchPortatoLyrics( + Dio dio, { + required String title, + required String artist, + String? album, + int? durationSeconds, +}) async { + if (title.trim().isEmpty || artist.trim().isEmpty) return null; + final attempts = [ + (title, artist), + (lyricsForSearchTitle(title), lyricsForSearchArtist(artist)), + ]; + for (final attempt in attempts) { + if (attempt.$1.trim().isEmpty || attempt.$2.trim().isEmpty) continue; + final qp = { + 's': attempt.$1.trim(), + 'a': attempt.$2.trim(), + if (durationSeconds != null && durationSeconds > 0) + 'd': '$durationSeconds', + if (album != null && album.trim().isNotEmpty) 'al': album.trim(), + }; + try { + final res = await dio.get( + 'https://lyrics-api.boidu.dev/qq/getLyrics', + queryParameters: qp, + options: Options( + responseType: ResponseType.plain, + headers: {'Accept': 'application/json'}, + ), + ); + final body = res.data; + if (body == null || body.isEmpty) continue; + final parsed = parseBetterDocument(body); + if (parsed != null && + parsed.isNotEmpty && + lyricsPlausibleDuration(parsed, durationSeconds)) { + final payload = _unwrapBetterPayload(body) ?? body; + final wordSynced = _betterHasWordTiming(payload); + return LyricsResult( + lines: parsed, + isSynced: true, + isWordSynced: wordSynced, + plainLyrics: parsed.map((l) => l.text).join('\n'), + source: wordSynced + ? 'QQ Karaoke (Word-Sync)' + : 'QQ Karaoke (Line-Sync)', + ); + } + } catch (_) {} + } + return null; +} + +// -- Syllable-Sync (Bini / lyrics-api.binimum.org) ---------------------------- +Future fetchBiniLyrics( + Dio dio, { + required String title, + required String artist, + String? album, + int? durationSeconds, + String? isrc, +}) async { + if (title.trim().isEmpty) return null; + + Future>> query(Map qp) async { + try { + final res = await dio.get( + 'https://lyrics-api.binimum.org/', + queryParameters: qp, + options: Options( + headers: { + 'User-Agent': + 'LastWave-Desktop/1.0 (https://github.com/clash-projects/lastwave-desktop)', + 'Accept': 'application/json', + }, + ), + ); + final data = res.data; + if (data is Map && data['results'] is List) { + return (data['results'] as List) + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + } catch (_) {} + return const []; + } + + List> hits = []; + if (isrc != null && isrc.trim().isNotEmpty) { + hits = await query({'isrc': isrc.trim()}); + } + if (hits.isEmpty) { + final shaped = { + 'track': title.trim(), + 'artist': artist.trim(), + if (album != null && album.trim().isNotEmpty) 'album': album.trim(), + if (durationSeconds != null && durationSeconds > 0) + 'duration': durationSeconds.toString(), + }; + hits = await query(shaped); + if (hits.isEmpty) { + final q = artist.trim().isEmpty + ? title.trim() + : '${artist.trim()} - ${title.trim()}'; + hits = await query({'q': q}); + } + } + if (hits.isEmpty) return null; + + Map? best; + var bestScore = -1; + for (final hit in hits) { + final trackName = hit['track_name']?.toString() ?? ''; + final artistName = hit['artist_name']?.toString() ?? ''; + if (!lyricsSameVersion(title, trackName)) continue; + var score = 0; + final cleanT = lyricsForSearchTitle(trackName); + final reqT = lyricsForSearchTitle(title); + if (cleanT.toLowerCase() == reqT.toLowerCase()) { + score += 3; + } else if (lyricsTitlesMatchStrict(cleanT, reqT)) { + score += 1; + } + final cleanA = lyricsForSearchArtist(artistName); + final reqA = lyricsForSearchArtist(artist); + if (lyricsArtistsMatchStrict(cleanA, reqA)) score += 2; + final dur = + hit['duration'] is num ? (hit['duration'] as num).toInt() : null; + if (durationSeconds != null && + durationSeconds > 0 && + dur != null && + dur > 0) { + final diff = (dur - durationSeconds).abs(); + if (diff <= 3) { + score += 3; + } else if (diff <= 10) { + score += 1; + } + } + if (hit['timing_type']?.toString().toLowerCase() == 'word') score += 1; + if (score >= 5 && score > bestScore) { + bestScore = score; + best = hit; + } + } + if (best == null) return null; + + final lyricsUrl = best['lyricsUrl']?.toString(); + if (lyricsUrl == null || lyricsUrl.trim().isEmpty) return null; + + try { + final docRes = await dio.get( + lyricsUrl, + options: Options( + responseType: ResponseType.plain, + headers: { + 'User-Agent': + 'LastWave-Desktop/1.0 (https://github.com/clash-projects/lastwave-desktop)', + 'Accept': 'application/xml, text/xml, */*', + }, + ), + ); + final xml = docRes.data; + if (xml == null || xml.isEmpty) return null; + final lines = parseTtml(xml); + if (lines.isNotEmpty && lyricsPlausibleDuration(lines, durationSeconds)) { + final wordSynced = lines.any((l) => l.hasSyllables); + return LyricsResult( + lines: lines, + isSynced: true, + isWordSynced: wordSynced, + plainLyrics: lines.map((l) => l.text).join('\n'), + source: 'Syllable-Sync', + ); + } + } catch (_) {} + return null; +} + +// -- LyricsPlus (multi-mirror aggregation) ------------------------------------- +const _lyricsPlusMirrors = [ + 'https://lyricsplus.prjktla.my.id', + 'https://lyricsplus.atomix.one', + 'https://lyricsplus.binimum.org', + 'https://lyricsplus.prjktla.workers.dev', + 'https://lyricsplus-seven.vercel.app', + 'https://lyrics-plus-backend.vercel.app', +]; + +Future fetchLyricsPlus( + Dio dio, { + required String title, + required String artist, + String? album, + int? durationSeconds, + String? isrc, +}) async { + if (title.trim().isEmpty || artist.trim().isEmpty) return null; + + List mergeSyllables(List rawSyllables) { + final words = []; + final buf = StringBuffer(); + var start = 0; + var end = 0; + + void flush() { + if (buf.isNotEmpty) { + words.add(LyricSyllable( + timeMs: start, + durationMs: end > start ? end - start : 0, + text: buf.toString(), + )); + } + buf.clear(); + } + + for (final s in rawSyllables) { + if (s is! Map) continue; + final raw = s['text']?.toString() ?? ''; + final content = raw.trim(); + if (content.isEmpty) { + flush(); + continue; + } + final time = (s['time'] as num?)?.toInt(); + if (time == null) continue; + final duration = (s['duration'] as num?)?.toInt() ?? 0; + + final joins = buf.isNotEmpty && !raw.startsWith(RegExp(r'\s')); + if (!joins) flush(); + if (buf.isEmpty) { + start = time; + end = time; + } + buf.write(content); + final wordEnd = time + duration; + if (wordEnd > end) end = wordEnd; + if (raw.endsWith(' ') || raw.endsWith('\t')) flush(); + } + flush(); + return words; + } + + for (final host in _lyricsPlusMirrors) { + final qp = { + 'title': title.trim(), + 'artist': artist.trim(), + if (durationSeconds != null && durationSeconds > 0) + 'duration': '$durationSeconds', + if (album != null && album.trim().isNotEmpty) 'album': album.trim(), + if (isrc != null && isrc.trim().isNotEmpty) 'isrc': isrc.trim(), + }; + try { + final res = await dio.get( + '$host/v2/lyrics/get', + queryParameters: qp, + options: Options( + responseType: ResponseType.json, + headers: { + 'User-Agent': + 'LastWave-Desktop/1.0 (https://github.com/clash-projects/lastwave-desktop)', + 'Accept': 'application/json', + }, + ), + ); + final data = res.data; + if (data is Map && data['lyrics'] is List) { + final rawLines = data['lyrics'] as List; + final lines = []; + for (final item in rawLines) { + if (item is! Map) continue; + final time = (item['time'] as num?)?.toInt(); + if (time == null) continue; + final duration = (item['duration'] as num?)?.toInt() ?? 0; + final syllablesRaw = item['syllabus'] as List?; + final words = syllablesRaw != null + ? mergeSyllables(syllablesRaw) + : const []; + if (words.isNotEmpty) { + lines.add(LyricLine( + timeMs: math.min(time, words.first.timeMs), + durationMs: duration > 0 ? duration : 0, + text: words.map((w) => w.text).join(' '), + syllables: words, + )); + } else { + final text = item['text']?.toString().trim() ?? ''; + if (text.isNotEmpty) { + lines.add(LyricLine( + timeMs: time, + durationMs: duration > 0 ? duration : 0, + text: text, + )); + } + } + } + lines.sort((a, b) => a.timeMs.compareTo(b.timeMs)); + if (lines.isNotEmpty && + lyricsPlausibleDuration(lines, durationSeconds)) { + final wordSynced = lines.any((l) => l.hasSyllables); + return LyricsResult( + lines: lines, + isSynced: true, + isWordSynced: wordSynced, + plainLyrics: lines.map((l) => l.text).join('\n'), + source: wordSynced + ? 'LyricsPlus (Word-Sync)' + : 'LyricsPlus (Line-Sync)', + ); + } + } + } catch (_) {} + } + return null; +} + +// -- Unison (unison.boidu.dev) ------------------------------------------------ +Future fetchUnisonLyrics( + Dio dio, { + required String title, + required String artist, + String? album, + int? durationSeconds, +}) async { + if (title.trim().isEmpty || artist.trim().isEmpty) return null; + final qp = { + 'song': title.trim(), + 'artist': artist.trim(), + if (album != null && album.trim().isNotEmpty) 'album': album.trim(), + if (durationSeconds != null && durationSeconds > 0) + 'duration': '$durationSeconds', + }; + try { + final res = await dio.get( + 'https://unison.boidu.dev/lyrics', + queryParameters: qp, + options: Options( + responseType: ResponseType.json, + headers: { + 'User-Agent': + 'LastWave-Desktop/1.0 (https://github.com/clash-projects/lastwave-desktop)', + 'Accept': 'application/json', + }, + ), + ); + final data = res.data; + if (data is Map && data['success'] == true && data['data'] is Map) { + final entry = data['data'] as Map; + final lyricsText = entry['lyrics']?.toString() ?? ''; + if (lyricsText.trim().isEmpty) return null; + final format = entry['format']?.toString().toLowerCase() ?? ''; + final syncType = entry['syncType']?.toString().toLowerCase() ?? ''; + + List lines = []; + if (format == 'ttml') { + lines = parseTtml(lyricsText); + } else if (syncType == 'plain') { + lines = const []; + } else { + lines = parseLrc(lyricsText); + } + if (lines.isNotEmpty && + lyricsPlausibleDuration(lines, durationSeconds)) { + final bool wordSynced; + if (format == 'ttml') { + wordSynced = lines.any((l) => l.hasSyllables); + } else { + wordSynced = syncType == 'word' || + syncType == 'syllable' || + parseEnhancedLrc(lyricsText).isNotEmpty; + } + final cleanLines = wordSynced + ? lines + : [ + for (final l in lines) + LyricLine( + timeMs: l.timeMs, + durationMs: l.durationMs, + text: l.text, + transliteration: l.transliteration, + syllables: const [], + ), + ]; + return LyricsResult( + lines: cleanLines, + isSynced: true, + isWordSynced: wordSynced, + plainLyrics: cleanLines.map((l) => l.text).join('\n'), + source: wordSynced ? 'Unison (Word-Sync)' : 'Unison (Line-Sync)', + ); + } + } + } catch (_) {} + return null; +} + +// -- Megalobiz (megalobiz.com) ------------------------------------------------ +Future fetchMegalobizLyrics( + Dio dio, { + required String title, + required String artist, + int? durationSeconds, +}) async { + if (title.trim().isEmpty) return null; + try { + final qry = '$artist $title'.trim(); + final searchRes = await dio.get( + 'https://www.megalobiz.com/searchall', + queryParameters: {'qry': qry}, + options: Options( + responseType: ResponseType.plain, + headers: { + 'User-Agent': + 'LastWave-Desktop/1.0 (https://github.com/clash-projects/lastwave-desktop)', + 'Accept': 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8', + }, + ), + ); + final html = searchRes.data ?? ''; + final linkMatch = RegExp( + r'''href=["'](/lrc/maker/download/[^"']+)["']''', + caseSensitive: false, + ).firstMatch(html); + if (linkMatch == null) return null; + final path = linkMatch.group(1)!.replaceAll('&', '&'); + final pageRes = await dio.get( + 'https://www.megalobiz.com$path', + options: Options( + responseType: ResponseType.plain, + headers: { + 'User-Agent': + 'LastWave-Desktop/1.0 (https://github.com/clash-projects/lastwave-desktop)', + }, + ), + ); + final pageHtml = pageRes.data ?? ''; + final bodyMatch = RegExp( + r'''id=["']lrc_[^"']*_details["'][^>]*>(.*?)''', + caseSensitive: false, + dotAll: true, + ).firstMatch(pageHtml); + if (bodyMatch == null) return null; + var rawLrc = bodyMatch.group(1)!; + rawLrc = rawLrc + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<[^>]+>'), ''); + final unescaped = rawLrc + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); + final lines = parseLrc(unescaped); + if (lines.isNotEmpty && + lyricsPlausibleDuration(lines, durationSeconds)) { + return LyricsResult( + lines: lines, + isSynced: true, + isWordSynced: false, + plainLyrics: lines.map((l) => l.text).join('\n'), + source: 'Megalobiz (Line-Sync)', + ); + } + } catch (_) {} + return null; +} + +// -- Genius (genius.com) ------------------------------------------------------ +Future fetchGeniusLyrics( + Dio dio, { + required String title, + required String artist, +}) async { + if (title.trim().isEmpty) return null; + final query = artist.trim().isNotEmpty + ? '${artist.trim()} ${title.trim()}' + : title.trim(); + try { + final searchRes = await dio.get( + 'https://genius.com/api/search/multi', + queryParameters: {'q': query}, + options: Options( + responseType: ResponseType.json, + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)', + 'Accept': 'application/json', + }, + ), + ); + final data = searchRes.data; + if (data is! Map) return null; + final sections = data['response']?['sections'] as List?; + if (sections == null) return null; + Map? songSection; + for (final s in sections) { + if (s is Map && s['type'] == 'song') { + songSection = Map.from(s); + break; + } + } + final hits = songSection?['hits'] as List?; + if (hits == null || hits.isEmpty) return null; + String? songUrl; + for (final h in hits) { + if (h is Map && h['result'] is Map) { + songUrl = h['result']['url']?.toString(); + if (songUrl != null && songUrl.isNotEmpty) break; + } + } + if (songUrl == null) return null; + + final pageRes = await dio.get( + songUrl, + options: Options( + responseType: ResponseType.plain, + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)', + }, + ), + ); + final html = pageRes.data ?? ''; + final containerRegex = RegExp( + r''']*data-lyrics-container=["']true["'][^>]*>(.*?)''', + dotAll: true, + caseSensitive: false, + ); + final matches = containerRegex.allMatches(html); + if (matches.isEmpty) return null; + final sb = StringBuffer(); + for (final m in matches) { + var chunk = m.group(1) ?? ''; + chunk = + chunk.replaceAll(RegExp(r'', caseSensitive: false), '\n'); + chunk = chunk.replaceAll(RegExp(r'<[^>]+>'), ''); + chunk = chunk + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); + if (sb.isNotEmpty) sb.write('\n\n'); + sb.write(chunk.trim()); + } + final text = sb.toString().trim(); + if (text.isNotEmpty) { + return LyricsResult( + lines: const [], + isSynced: false, + isWordSynced: false, + plainLyrics: text, + source: 'Genius (Plain)', + ); + } + } catch (_) {} + return null; +} + +// -- LastWave (lyrics-api.boidu.dev/lastwave) ---------------------------------- +Future fetchLastWaveLyrics( + Dio dio, { + required String title, + required String artist, + String? album, + int? durationSeconds, + String? isrc, +}) async { + const base = 'https://lyrics-api.boidu.dev/lastwave'; + final qp = { + 'title': title.trim(), + if (artist.trim().isNotEmpty) 'artist': artist.trim(), + if (album != null && album.trim().isNotEmpty) 'album': album.trim(), + if (durationSeconds != null && durationSeconds > 0) + 'duration': '$durationSeconds', + if (isrc != null && isrc.trim().isNotEmpty) 'isrc': isrc.trim(), + }; + try { + final res = await dio.get( + '$base/lyrics', + queryParameters: qp, + options: Options( + responseType: ResponseType.plain, + headers: { + 'User-Agent': + 'LastWave-Desktop/1.0 (https://github.com/clash-projects/lastwave-desktop)', + 'Accept': 'application/json, text/plain', + }, + ), + ); + final body = res.data; + if (body == null || body.trim().isEmpty) return null; + final payload = _unwrapBetterPayload(body) ?? body.trim(); + final lines = parseBetterDocument(payload); + if (lines != null && + lines.isNotEmpty && + lyricsPlausibleDuration(lines, durationSeconds)) { + final wordSynced = _betterHasWordTiming(payload); + return LyricsResult( + lines: lines, + isSynced: true, + isWordSynced: wordSynced, + plainLyrics: lines.map((l) => l.text).join('\n'), + source: + wordSynced ? 'LastWave (Word-Sync)' : 'LastWave (Line-Sync)', + ); + } + } catch (_) {} + return null; +} diff --git a/lib/features/lyrics/lyrics_repository.dart b/lib/features/lyrics/lyrics_repository.dart index 4d2a07e..fe954f4 100644 --- a/lib/features/lyrics/lyrics_repository.dart +++ b/lib/features/lyrics/lyrics_repository.dart @@ -3,11 +3,15 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:unorm_dart/unorm_dart.dart' as unorm; import '../../core/network/dio_factory.dart'; +import '../innertube/innertube_api.dart'; import 'lyrics_models.dart'; import 'lyrics_providers.dart'; +final _diacriticsRegex = RegExp(r'\p{M}+', unicode: true); + /// Lyrics orchestrator with Apple Music lyrics via Lyrically. /// /// Ported from LastWave-native `LyricsRepository.kt`: @@ -21,20 +25,23 @@ String cleanSongTitle(String title) { var t = title; t = t.replaceAll( RegExp( - r'\s*\([^)]*(?:official|video|audio|remaster|feat|ft\.|live|version|edit|visualizer|lyrics?)[^)]*\)', + r'\s*\([^)]*(?:official|video|audio|remaster|feat\b|ft\.?|featuring\b|live|version|edit|visualizer|lyrics?|hd|hq|4k|1080p|mv|m/v|full\s*song|full\s*video|full\s*audio|explicit|clean|deluxe|bonus|with|prod)[^)]*\)', caseSensitive: false), ''); t = t.replaceAll( RegExp( - r'\s*\[[^\]]*(?:official|video|audio|remaster|feat|ft\.|live|version|edit|visualizer|lyrics?)[^\]]*\]', + r'\s*\[[^\]]*(?:official|video|audio|remaster|feat\b|ft\.?|featuring\b|live|version|edit|visualizer|lyrics?|hd|hq|4k|1080p|mv|m/v|full\s*song|full\s*video|full\s*audio|explicit|clean|deluxe|bonus|with|prod)[^\]]*\]', caseSensitive: false), ''); t = t.replaceAll( RegExp( - r'\s*-\s*(?:official|video|audio|remaster|live|remastered|lyrics?).*$', + r'\s*[-–—|/:]\s*(?:official|video|audio|remaster|live|remastered|lyrics?|hd|hq|4k|1080p|mv|m/v|deluxe|bonus|visualizer|music\s*video|lyric\s*video|full\s*(?:song|video|audio)|prod).*$', caseSensitive: false), ''); - t = t.replaceAll(RegExp(r'[\s\-–—]+$'), '').trim(); + t = t.replaceAll( + RegExp(r'\s+(?:feat\.?|ft\.?|featuring)\b.*$', caseSensitive: false), + ''); + t = t.replaceAll(RegExp(r'[\s\-–—|/:]+$'), '').trim(); return t.isNotEmpty ? t : title; } @@ -42,22 +49,30 @@ String cleanSongArtist(String artist) { var a = artist; a = a.replaceAll(RegExp(r'\s*-\s*Topic$', caseSensitive: false), ''); a = a.replaceAll( - RegExp(r'\s*(?:feat\.|ft\.|featuring).*$', caseSensitive: false), ''); + RegExp(r'\s*[\(\[]\s*(?:feat\.?|ft\.?|featuring\b|with\b|prod\.?|produced by\b)[^\)\]]*[\)\]]', caseSensitive: false), + ''); + a = a.replaceAll( + RegExp(r'\s*(?:feat\.?|ft\.?|featuring\b|with\b).*$', caseSensitive: false), + ''); return a.trim().isNotEmpty ? a.trim() : artist; } /// Drop featuring clauses, bracketed noise, and punctuation so "ALL CAPS" /// and "All Caps [Official Audio]" compare equal. String normalizeLyricsTitle(String s) { - var t = s.toLowerCase(); + var t = s + .replaceAll(RegExp(r"[\u2018\u2019\u201A\u201B`']"), '') + .replaceAll(RegExp(r'[\u201C\u201D\u201E\u201F"]'), ' ') + .replaceAll(RegExp(r'[\u2013\u2014\u2212-]'), ' '); + t = unorm.nfd(t.toLowerCase()); + t = t.replaceAll(_diacriticsRegex, ''); t = t.replaceAll(RegExp(r'\$(?=\d)'), ''); t = t.replaceAll(r'$', 's'); t = t.replaceAll(RegExp(r'\[[^\]]*\]'), ' '); t = t.replaceAll(RegExp(r'\b(?:feat\.?|ft\.?|featuring)\b.*$'), ' '); t = t.replaceAll(RegExp(r'\(\s*\)'), ' '); t = t.replaceAll(RegExp(r'[\(\[]\s*$'), ' '); - t = t.replaceAll(RegExp(r'[‘’`]'), "'"); - t = t.replaceAll(RegExp(r"[^\w\s()'&]"), ' '); + t = t.replaceAll(RegExp(r"[^\w\s()&]"), ' '); t = t.replaceAll(RegExp(r'\s+'), ' ').trim(); return t; } @@ -73,18 +88,51 @@ String _lyricsCoreTitle(String normalized) { /// Substring matches ("All Caps" ⊂ "Scene Three"? no; "All" ⊂ "All Caps"? /// also no) are rejected so a different track cannot steal lyrics. bool lyricsTitlesMatch(String query, String candidate) { - final q = normalizeLyricsTitle(query); - final c = normalizeLyricsTitle(candidate); + // Version gate: recording versions (live, remix, acoustic, cover, interlude, etc.) must agree. + // Gated first so cleaning packaging never causes alternate cuts to compare equal. + if (!lyricsSameVersion(query, candidate)) return false; + + final cleanQ = cleanSongTitle(query); + final cleanC = cleanSongTitle(candidate); + var q = normalizeLyricsTitle(cleanQ); + var c = normalizeLyricsTitle(cleanC); if (q.isEmpty || c.isEmpty) return false; if (q == c) return true; - final qCore = _lyricsCoreTitle(q); - final cCore = _lyricsCoreTitle(c); - if (qCore.isEmpty || cCore.isEmpty || qCore != cCore) return false; - final qExtra = q.replaceAll(qCore, '').trim(); - final cExtra = c.replaceAll(cCore, '').trim(); - // Query named a specific cut the candidate does not have - // ("Song (Interlude)" must not match "Song"). + + var qCore = _lyricsCoreTitle(q); + var cCore = _lyricsCoreTitle(c); + if (qCore.isEmpty || cCore.isEmpty || qCore != cCore) { + final qStrip = normalizeLyricsTitle(stripLeadingArtistPrefix(cleanQ)); + final cStrip = normalizeLyricsTitle(stripLeadingArtistPrefix(cleanC)); + if (qStrip != q || cStrip != c) { + final qStripCore = _lyricsCoreTitle(qStrip); + final cStripCore = _lyricsCoreTitle(cStrip); + if (qStripCore.isNotEmpty && qStripCore == cStripCore) { + q = qStrip; + c = cStrip; + qCore = qStripCore; + cCore = cStripCore; + } else { + return false; + } + } else { + return false; + } + } + + String stripPackaging(String s) { + var v = s.toLowerCase(); + v = v.replaceAll( + RegExp(r'\b(official|video|audio|remaster(?:ed)?|feat|ft|featuring|with|prod|live|version|edit|visualizer|lyrics?|hd|hq|4k|mv|deluxe|bonus|clean|explicit|album|single|original)\b', caseSensitive: false), + ' ', + ); + return v.replaceAll(RegExp(r"[^\w]"), '').trim(); + } + + final qExtra = stripPackaging(q.replaceAll(qCore, '')); + final cExtra = stripPackaging(c.replaceAll(cCore, '')); if (qExtra.isNotEmpty && cExtra.isEmpty) return false; + if (qExtra.isEmpty && cExtra.isNotEmpty) return false; if (qExtra.isNotEmpty && cExtra.isNotEmpty && qExtra != cExtra) { return false; } @@ -92,25 +140,64 @@ bool lyricsTitlesMatch(String query, String candidate) { } String _normalizeLyricsArtist(String s) { - var v = s.toLowerCase(); + var v = s + .replaceAll(RegExp(r"[\u2018\u2019\u201A\u201B`']"), '') + .replaceAll(RegExp(r'[\u201C\u201D\u201E\u201F"]'), ' ') + .replaceAll(RegExp(r'[\u2013\u2014\u2212-]'), ' '); + v = unorm.nfd(v.toLowerCase()); + v = v.replaceAll(_diacriticsRegex, ''); v = v.replaceAll(RegExp(r'\$(?=\d)'), ''); v = v.replaceAll(r'$', 's'); return v; } -/// Artists match when equal, or the shorter name is a full token in the -/// longer billing ("Madvillain" in "Madvillain & MF DOOM"). +Set _extractArtistEntities(String rawArtist) { + final cleaned = cleanSongArtist(rawArtist); + final normalized = _normalizeLyricsArtist(cleaned).trim(); + final entities = {}; + if (normalized.isNotEmpty) entities.add(normalized); + final parts = normalized.split( + RegExp(r'\s*[,/;&+]\s*|\s+(?:feat\.?|ft\.?|featuring|with|vs\.?|x)\s+', + caseSensitive: false), + ); + for (final p in parts) { + final t = p.trim(); + if (t.isNotEmpty) entities.add(t); + } + return entities; +} + +/// Artists match when equal, or collaborating entities match across delimiters +/// (&, /, ,, +, with, feat, vs, x) while preventing false positives. bool lyricsArtistsMatch(String query, String candidate) { final q = _normalizeLyricsArtist(cleanSongArtist(query)).trim(); final c = _normalizeLyricsArtist(cleanSongArtist(candidate)).trim(); if (q.isEmpty || c.isEmpty) return false; if (q == c) return true; + + final qEntities = _extractArtistEntities(query); + final cEntities = _extractArtistEntities(candidate); + + for (final qe in qEntities) { + if (qe.length < 2) continue; + for (final ce in cEntities) { + if (ce.length < 2) continue; + if (qe == ce) return true; + } + } + final shorter = q.length <= c.length ? q : c; final longer = q.length <= c.length ? c : q; - if (shorter.length < 2) return false; - return RegExp( - '(^|[\\s&/,;+])${RegExp.escape(shorter)}(\$|[\\s&/,;+])', - ).hasMatch(longer); + if (shorter.length >= 2) { + final pattern = + r'(^|(?:\s*[,&/;+]\s*|\s+(?:with|feat\.?|ft\.?|featuring|vs\.?|x)\s+))' + + RegExp.escape(shorter) + + r'($|(?:\s*[,&/;+]\s*|\s+(?:with|feat\.?|ft\.?|featuring|vs\.?|x)\s+))'; + if (RegExp(pattern, caseSensitive: false).hasMatch(longer)) { + return true; + } + } + return false; } bool lyricsIsAlternateRecording(String title, {String album = ''}) { @@ -132,8 +219,8 @@ bool lyricsDurationPlausible(int? querySeconds, num? candidateSeconds) { final candidate = candidateSeconds.round(); if (candidate <= 0) return true; final diff = (candidate - querySeconds).abs(); - if (diff <= 12) return true; - return diff / querySeconds <= 0.2; + if (diff <= 25) return true; + return diff <= 45 && (diff / querySeconds <= 0.18); } int lyricsUniqueLineCount(LyricsResult result) { @@ -239,6 +326,7 @@ LyricsResult lyricsForDisplayMode( class LyricsRepository { final Dio _dio; + final InnerTubeMusicApi? _tube; /// Bounded LRU (insertion-ordered map): one entry per unique track /// incl. full karaoke syllable trees, so an unbounded map leaks @@ -256,9 +344,18 @@ class LyricsRepository { Dio? dio, this.preferredHeadStart = const Duration(seconds: 4), this.raceTotal = const Duration(seconds: 12), - ]) : _dio = dio ?? DioFactory.create(); - - void _store(String key, LyricsResult value) { + InnerTubeMusicApi? tube, + ]) : _dio = dio ?? DioFactory.create(), + _tube = tube; + + void _store(String key, LyricsResult value, {String queryTitle = ''}) { + if (value.isEmpty) return; + final existing = _cache[key]; + if (existing != null && !existing.isEmpty) { + if (isBetterCandidate(existing, value, queryTitle: queryTitle)) { + return; + } + } _cache ..remove(key) ..[key] = value; @@ -297,12 +394,19 @@ class LyricsRepository { '${_key(title, artist, album)}|${durationSeconds ?? 0}|$wordByWord|${preferred.id}|${sortedExcludes.join(',')}'; if (!forceRefresh) { final cached = _lookup(key); - // Empty results are never stored (see below), so any cache hit - // is usable: serve it. Falling through here would refetch on - // every rebuild and could overwrite a good entry with empty. + // Empty results are never stored, so any cache hit is usable. + // If word-by-word was requested, only return from cache immediately + // if the cached entry is word-synced (or instrumental). + // If the cached entry is only line-synced, stream it as an immediate + // partial so the UI has lyrics instantly, but allow the race below + // to seek a true word-synced version. if (cached != null) { - onPartialResult?.call(cached); - return lyricsForDisplayMode(cached, wordByWord: wordByWord); + if (!wordByWord || cached.isWordSynced || cached.isInstrumental) { + onPartialResult?.call(cached); + return lyricsForDisplayMode(cached, wordByWord: wordByWord); + } else { + onPartialResult?.call(lyricsForDisplayMode(cached, wordByWord: wordByWord)); + } } } @@ -347,20 +451,24 @@ class LyricsRepository { ); } - // Preferred LRCLIB goes first so the explicit choice is honored. - // A word-sync hit from anywhere later still wins. + // Preferred non-word provider (LRCLIB, Musixmatch, Megalobiz, YouTube, Genius) + // goes first so explicit choice is honored. A word-sync hit from anywhere later + // can still supersede unless line mode was requested. var lrclibAttempted = false; LyricsResult? preferredFallback; - if (preferred == LyricsProviderId.lrclib) { - lrclibAttempted = true; - final first = normalize(await attempt('lrclib')); + if (preferred != LyricsProviderId.auto && + !preferred.isWordProvider && + !excludes.contains(preferred.id)) { + if (preferred == LyricsProviderId.lrclib) lrclibAttempted = true; + final first = normalize(await attempt(preferred.id)); if (first != null) { if (first.isWordSynced || (first.isInstrumental && wantsAlt)) { - _store(key, first); + _store(key, first, queryTitle: title); return display(first); } if (plausible(first)) { preferredFallback = first; + lineFallback = first; onPartialResult?.call(display(first)); } } @@ -388,14 +496,15 @@ class LyricsRepository { } if (single != null && plausible(single)) { if (single.isWordSynced && wordByWord) { - _store(key, single); + _store(key, single, queryTitle: title); return display(single); } if (single.isInstrumental && wantsAlt) { - _store(key, single); + _store(key, single, queryTitle: title); return display(single); } preferredFallback = single; + lineFallback = single; onPartialResult?.call(display(single)); } } @@ -421,9 +530,14 @@ class LyricsRepository { LyricsResult? raceWord; final pending = >[ for (final id in const [ + 'lastwave', 'lrc_red', 'apple_music', 'better_lyrics', + 'better_lyrics_portato', + 'bini_lyrics', + 'lyrics_plus', + 'unison', 'kugou', 'simp_music', 'lrclib', @@ -438,8 +552,11 @@ class LyricsRepository { if (result == null || result.isEmpty) continue; if (raceWord != null) continue; if (result.isWordSynced && plausible(result)) { - raceWord = result; - return; + if (!lyricsLooksLikePlaceholder(result) && + !lyricsLooksLikeThinLoop(result)) { + raceWord = result; + return; + } } if (!plausible(result)) continue; if (result.isInstrumental && !wantsAlt) continue; @@ -460,7 +577,7 @@ class LyricsRepository { } final won = raceWord; if (won != null) { - _store(key, won); + _store(key, won, queryTitle: title); return display(won); } } else { @@ -482,29 +599,57 @@ class LyricsRepository { // Explicit choice outranks any other line-sync source. final preferredSettled = preferredFallback; if (preferredSettled != null) { - _store(key, preferredSettled); + _store(key, preferredSettled, queryTitle: title); return display(preferredSettled); } final lineSettled = lineFallback; if (lineSettled != null) { - _store(key, lineSettled); + _store(key, lineSettled, queryTitle: title); return display(lineSettled); } - // Extra line-sync catalogue after the race. + // Extra line-sync & plain catalogue after the race. final mxm = normalize(await attempt('musixmatch')); if (mxm != null && plausible(mxm)) { - _store(key, mxm); + _store(key, mxm, queryTitle: title); return display(mxm); } + final mb = normalize(await attempt('megalobiz')); + if (mb != null && plausible(mb)) { + _store(key, mb, queryTitle: title); + return display(mb); + } + + if (videoId?.isNotEmpty ?? false) { + final captions = normalize(await attempt('youtube_captions')); + if (captions != null && plausible(captions)) { + _store(key, captions, queryTitle: title); + return display(captions); + } + + final ytm = normalize(await attempt('youtube_music')); + if (ytm != null && plausible(ytm)) { + _store(key, ytm, queryTitle: title); + return display(ytm); + } + } + // LRCLIB line fallback (skipped when already tried as preferred). if (!lrclibAttempted) { final lrc = normalize(await attempt('lrclib')); if (lrc != null) { - _store(key, lrc); + _store(key, lrc, queryTitle: title); return display(lrc); } } + + // Genius static text fallback. + final gn = normalize(await attempt('genius')); + if (gn != null && plausible(gn)) { + _store(key, gn, queryTitle: title); + return display(gn); + } + // Nothing usable: return empty WITHOUT caching it, so a later // retry (or the next rebuild) actually refetches instead of // replaying this miss. @@ -523,6 +668,14 @@ class LyricsRepository { }) async { try { switch (providerId) { + case 'lastwave': + return await fetchLastWaveLyrics( + _dio, + title: title, + artist: artist, + album: album, + durationSeconds: durationSeconds, + ); case 'lrc_red': return await fetchLrcRed( _dio, @@ -532,7 +685,8 @@ class LyricsRepository { durationSeconds: durationSeconds, ); case 'apple_music': - return await _fetchAppleWordByWord(title, artist, album, durationSeconds); + return await _fetchAppleWordByWord( + title, artist, album, durationSeconds); case 'better_lyrics': return await fetchBetterLyrics( _dio, @@ -541,6 +695,38 @@ class LyricsRepository { album: album, durationSeconds: durationSeconds, ); + case 'better_lyrics_portato': + return await fetchPortatoLyrics( + _dio, + title: title, + artist: artist, + album: album, + durationSeconds: durationSeconds, + ); + case 'bini_lyrics': + return await fetchBiniLyrics( + _dio, + title: title, + artist: artist, + album: album, + durationSeconds: durationSeconds, + ); + case 'lyrics_plus': + return await fetchLyricsPlus( + _dio, + title: title, + artist: artist, + album: album, + durationSeconds: durationSeconds, + ); + case 'unison': + return await fetchUnisonLyrics( + _dio, + title: title, + artist: artist, + album: album, + durationSeconds: durationSeconds, + ); case 'kugou': return await fetchKugou( _dio, @@ -567,6 +753,44 @@ class LyricsRepository { ? 'Video-Match (Word-Sync)' : 'Video-Match (Line-Sync)', ); + case 'youtube_captions': + if (videoId == null || videoId.isEmpty || _tube == null) return null; + final cues = await _tube.fetchTranscriptCues(videoId); + if (cues == null || cues.isEmpty) return null; + final lines = [ + for (final c in cues) + LyricLine( + timeMs: c.timeMs, + text: c.text, + ), + ]; + if (!lyricsPlausibleDuration(lines, durationSeconds)) return null; + return LyricsResult( + lines: lines, + isSynced: true, + isWordSynced: false, + plainLyrics: lines.map((l) => l.text).join('\n'), + source: 'YouTube Captions', + ); + case 'youtube_music': + if (videoId == null || videoId.isEmpty || _tube == null) return null; + final text = await _tube.fetchLyricsTabText(videoId); + if (text == null || text.trim().isEmpty) return null; + final rawLines = text + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + return LyricsResult( + lines: [ + for (final line in rawLines) + LyricLine(timeMs: 0, text: line), + ], + isSynced: false, + isWordSynced: false, + plainLyrics: text.trim(), + source: 'YouTube Music', + ); case 'musixmatch': return await fetchMusixmatch( _dio, @@ -574,6 +798,19 @@ class LyricsRepository { artist: artist, durationSeconds: durationSeconds, ); + case 'megalobiz': + return await fetchMegalobizLyrics( + _dio, + title: title, + artist: artist, + durationSeconds: durationSeconds, + ); + case 'genius': + return await fetchGeniusLyrics( + _dio, + title: title, + artist: artist, + ); case 'lrclib': return await _fetchLrclib(title, artist, album, durationSeconds); } @@ -599,13 +836,18 @@ class LyricsRepository { if (!newPlaceholder && curPlaceholder) return true; if (newPlaceholder && !curPlaceholder) return false; - // 3. Sample-hook loops lose to real verses (Overdue / Anthonio) + // 3. Timed lyrics always beat static text: static lines can never + // highlight or follow, no matter how complete or title-matched. + if (newRes.isSynced && !current.isSynced) return true; + if (!newRes.isSynced && current.isSynced) return false; + + // 4. Sample-hook loops lose to real verses (Overdue / Anthonio) final newThin = lyricsLooksLikeThinLoop(newRes); final curThin = lyricsLooksLikeThinLoop(current); if (!newThin && curThin) return true; if (newThin && !curThin) return false; - // 4. Distinctive title mentioned in the lyric body + // 5. Distinctive title mentioned in the lyric body if (queryTitle.trim().isNotEmpty) { final newMentions = lyricsMentionsTitle(queryTitle, newRes); final curMentions = lyricsMentionsTitle(queryTitle, current); @@ -613,12 +855,6 @@ class LyricsRepository { if (!newMentions && curMentions) return false; } - // 5. Timed lyrics always beat static text: static lines can never - // highlight or follow, no matter how complete. Length/richness - // below only compare like with like. - if (newRes.isSynced && !current.isSynced) return true; - if (!newRes.isSynced && current.isSynced) return false; - // 6. Complete text beats a shorter cut of the same sync kind final newLen = lyricsBodyLength(newRes); final curLen = lyricsBodyLength(current); @@ -737,6 +973,12 @@ class LyricsRepository { addRecord(hit); } } + final primaryA = _extractArtistEntities(cleanA).firstOrNull; + if (primaryA != null && primaryA != cleanA && records.isEmpty) { + for (final hit in await _lrclibSearchHits(cleanT, primaryA, durationSeconds)) { + addRecord(hit); + } + } LyricsResult? best; for (final record in records.values) { @@ -933,8 +1175,29 @@ class LyricsRepository { ), ); final decoded = jsonDecode(res.data ?? ''); - final results = + var results = decoded is Map ? decoded['results'] : null; + if (results is! List || results.isEmpty) { + final primaryArtist = _extractArtistEntities(artist).firstOrNull; + if (primaryArtist != null && primaryArtist != artist) { + final res2 = await _dio.get( + 'https://itunes.apple.com/search', + queryParameters: { + 'term': '$primaryArtist $title', + 'media': 'music', + 'entity': 'song', + 'limit': '10', + }, + options: Options( + headers: {'User-Agent': DioFactory.desktopUserAgent}, + responseType: ResponseType.plain, + ), + ); + final decoded2 = jsonDecode(res2.data ?? ''); + results = + decoded2 is Map ? decoded2['results'] : null; + } + } if (results is! List || results.isEmpty) return const []; final wantsAlt = lyricsIsAlternateRecording(title); @@ -1042,9 +1305,12 @@ class LyricsRepository { syllables: syllables, )); } - if (parsed.isEmpty) return null; final timed = parsed.any((p) => p.start > 0 || p.duration > 0); - final wordSynced = timed && (type == 'syllable' || sawMultiWordLine); + final wordSynced = timed && + (type == 'syllable' || + type == 'word' || + sawMultiWordLine || + parsed.any((p) => p.syllables.length > 1)); final rawLines = [ for (final p in parsed) LyricLine( @@ -1076,5 +1342,11 @@ class LyricsRepository { } } -final lyricsRepositoryProvider = - Provider((_) => LyricsRepository()); +final lyricsRepositoryProvider = Provider((ref) { + return LyricsRepository( + null, + const Duration(seconds: 4), + const Duration(seconds: 12), + ref.watch(innerTubeProvider), + ); +}); diff --git a/lib/ui/lyrics/lyrics_panel.dart b/lib/ui/lyrics/lyrics_panel.dart index 6e57cc8..0747818 100644 --- a/lib/ui/lyrics/lyrics_panel.dart +++ b/lib/ui/lyrics/lyrics_panel.dart @@ -19,48 +19,85 @@ final lyricsExcludedProvidersProvider = StateProvider.autoDispose.family, String>((_, _) => {}); final waveLyricsProvider = StreamProvider.autoDispose - .family((ref, key) { - final current = ref.watch( - playbackServiceProvider.select((s) => s.current)); - if (current == null || current.queueKey != key) { - return Stream.value(const LyricsResult.empty()); - } + .family((ref, track) { final repo = ref.watch(lyricsRepositoryProvider); - final prefs = ref.watch(prefsProvider); - final wordByWord = prefs.wordByWord; + final wordByWord = ref.watch(prefsProvider.select((p) => p.wordByWord)); + final defaultProviderId = + ref.watch(prefsProvider.select((p) => p.lyricsProviderId)); // Per-track source control (Task 9): an explicit provider pick // (null = follow the Settings default) plus excluded providers. // Watched, so switching sources refetches through a new cache key. - final override = ref.watch(lyricsProviderOverrideProvider(key)); - final excludes = ref.watch(lyricsExcludedProvidersProvider(key)); - // Duration read once (no watch) so late duration arrival doesn't refetch. - final durationSecs = - ref.read(playbackServiceProvider).duration.inSeconds; + final override = ref.watch(lyricsProviderOverrideProvider(track.queueKey)); + final excludes = ref.watch(lyricsExcludedProvidersProvider(track.queueKey)); + // Duration read initially; if 0 (stream loading/buffering), we listen for arrival + // so lyrics are fetched automatically without requiring a manual refresh. + final playback = ref.read(playbackServiceProvider); + final initialDurationSecs = playback.current?.queueKey == track.queueKey + ? playback.duration.inSeconds + : 0; final results = StreamController(); var disposed = false; + var hasEmittedContent = false; ref.onDispose(() { disposed = true; unawaited(results.close()); }); - unawaited(repo.getLyrics( - title: current.title, - artist: current.artist, - album: current.album, - durationSeconds: durationSecs > 0 ? durationSecs : null, - wordByWord: wordByWord, - videoId: current.videoId.isNotEmpty ? current.videoId : null, - preferredProviderId: override ?? prefs.lyricsProviderId, - excludeProviderIds: excludes, - onPartialResult: (result) { - if (!disposed) results.add(result); - }, - ).then((result) { - if (!disposed) results.add(result); - }, onError: (Object error, StackTrace stack) { - if (!disposed) results.addError(error, stack); - }).whenComplete(() { - if (!disposed) unawaited(results.close()); - })); + + void runFetch(int? secs) { + unawaited(repo.getLyrics( + title: track.title, + artist: track.artist, + album: track.album, + durationSeconds: secs != null && secs > 0 ? secs : null, + wordByWord: wordByWord, + videoId: track.videoId.isNotEmpty ? track.videoId : null, + preferredProviderId: override ?? defaultProviderId, + excludeProviderIds: excludes, + onPartialResult: (result) { + if (!disposed && !result.isEmpty) { + hasEmittedContent = true; + results.add(result); + } + }, + ).then((result) { + if (!disposed) { + if (!result.isEmpty) { + hasEmittedContent = true; + results.add(result); + } else if (!hasEmittedContent && (secs == null || secs <= 0)) { + final curSecs = ref.read(playbackServiceProvider).duration.inSeconds; + if (curSecs > 0) { + runFetch(curSecs); + return; + } + results.add(result); + } else if (!hasEmittedContent) { + results.add(result); + } + } + }, onError: (Object error, StackTrace stack) { + if (!disposed) results.addError(error, stack); + }).whenComplete(() { + if (!disposed && (hasEmittedContent || (secs != null && secs > 0))) { + unawaited(results.close()); + } + })); + } + + runFetch(initialDurationSecs > 0 ? initialDurationSecs : null); + + if (initialDurationSecs <= 0) { + ref.listen( + playbackServiceProvider.select((s) => + s.current?.queueKey == track.queueKey ? s.duration.inSeconds : 0), + (prev, next) { + if (!disposed && !hasEmittedContent && next > 0 && (prev == null || prev <= 0)) { + runFetch(next); + } + }, + ); + } + return results.stream; }); @@ -73,6 +110,7 @@ class WaveLyricsPanel extends StatelessWidget { final bool compact; final bool showHeaderControls; final VoidCallback? onClose; + final bool isActive; const WaveLyricsPanel({ super.key, @@ -80,6 +118,7 @@ class WaveLyricsPanel extends StatelessWidget { this.compact = false, this.showHeaderControls = true, this.onClose, + this.isActive = true, }); @override @@ -89,6 +128,7 @@ class WaveLyricsPanel extends StatelessWidget { compact: compact, showHeaderControls: showHeaderControls, onClose: onClose, + isActive: isActive, ); } } diff --git a/lib/ui/lyrics/lyrics_side_panel.dart b/lib/ui/lyrics/lyrics_side_panel.dart index 4b8d9ff..1759648 100644 --- a/lib/ui/lyrics/lyrics_side_panel.dart +++ b/lib/ui/lyrics/lyrics_side_panel.dart @@ -133,6 +133,7 @@ class _WaveLyricsSidePanelState current: current, dark: dark, onClose: widget.onClose, + visible: widget.visible, ), ], ), @@ -144,10 +145,12 @@ class _PanelBody extends ConsumerWidget { final PlayableTrack? current; final bool dark; final VoidCallback onClose; + final bool visible; const _PanelBody({ required this.current, required this.dark, required this.onClose, + this.visible = true, }); @override @@ -255,6 +258,7 @@ class _PanelBody extends ConsumerWidget { fontSize: 34, showHeaderControls: true, onClose: null, + isActive: visible, ), ), ), diff --git a/lib/ui/settings/settings_page.dart b/lib/ui/settings/settings_page.dart index 4dc36ce..ba05140 100644 --- a/lib/ui/settings/settings_page.dart +++ b/lib/ui/settings/settings_page.dart @@ -1745,11 +1745,12 @@ class _Sources extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (AppEnv.addonClientSecret.isEmpty) + if (AppEnv.addonClientSecret.isEmpty && + urls.any((u) => AddonApi.tokenOfRoot(u).isNotEmpty)) const Padding( padding: EdgeInsets.only(bottom: 8), child: Text( - 'This build has no addon app key — addon calls will fail. ' + 'This build has no addon app key — official addons may fail. ' 'Rebuild with ADDON_CLIENT_SECRET set.', style: TextStyle(fontSize: 12), ), @@ -1797,7 +1798,7 @@ class _Sources extends ConsumerWidget { title: const Text('Add addon'), content: TextBox( controller: controller, - placeholder: 'https://…/a//', + placeholder: 'https://…/a// or https://…', maxLines: 2, ), actions: [ @@ -1823,7 +1824,7 @@ class _Sources extends ConsumerWidget { builder: (dialogContext) => ContentDialog( title: const Text('Not an addon URL'), content: const Text( - 'Expected something like https://host/a//.', + 'Expected something like https://host/a// or https://host/.', ), actions: [ FilledButton( @@ -1939,7 +1940,7 @@ class _About extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('LastWave Desktop · v1.2.0', style: WaveType.trackTitle), + Text('LastWave Desktop · v1.3.0', style: WaveType.trackTitle), const SizedBox(height: 10), Wrap( spacing: 8, diff --git a/pubspec.yaml b/pubspec.yaml index 6124cdd..f510123 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.0+4 +version: 1.3.0+5 environment: sdk: ^3.13.3 diff --git a/test/addon_api_test.dart b/test/addon_api_test.dart index f81dd25..812ce14 100644 --- a/test/addon_api_test.dart +++ b/test/addon_api_test.dart @@ -38,10 +38,24 @@ void main() { expect(p?.root, 'https://x.test/a/${_hex('d')}/'); }); - test('bare domain rejected', () { - expect( - AddonApi.parseAddonUrl('https://x.test/'), isNull); - expect(AddonApi.parseAddonUrl('https://x.test'), isNull); + test('bare domain accepted as root', () { + final p1 = AddonApi.parseAddonUrl('https://x.test/'); + expect(p1?.root, 'https://x.test/'); + expect(p1?.token, ''); + + final p2 = AddonApi.parseAddonUrl('https://x.test'); + expect(p2?.root, 'https://x.test/'); + expect(p2?.token, ''); + + final p3 = AddonApi.parseAddonUrl( + 'https://l-bitches.clashgram.workers.dev'); + expect(p3?.root, 'https://l-bitches.clashgram.workers.dev/'); + expect(p3?.token, ''); + + final p4 = AddonApi.parseAddonUrl( + 'https://l-bitches.clashgram.workers.dev/manifest.json'); + expect(p4?.root, 'https://l-bitches.clashgram.workers.dev/'); + expect(p4?.token, ''); }); test('short/non-hex tokens rejected', () { @@ -663,9 +677,15 @@ void main() { }); group('AddonApi gating', () { - test('unconfigured without bases or secret', () { + test('unconfigured without bases', () { expect(AddonApi(const []).isConfigured, isFalse); }); + + test('configured with bases even without secret', () { + expect( + AddonApi(['https://l-bitches.clashgram.workers.dev/']).isConfigured, + isTrue); + }); }); group('resolveStream transient retry', () { diff --git a/test/karaoke_lyrics_test.dart b/test/karaoke_lyrics_test.dart index c9418a5..7da06a4 100644 --- a/test/karaoke_lyrics_test.dart +++ b/test/karaoke_lyrics_test.dart @@ -306,6 +306,9 @@ void main() { expect(cleanSongTitle('Hotel California - Remastered 2013'), equals('Hotel California')); expect(cleanSongTitle('Blinding Lights (Live on SNL)'), equals('Blinding Lights')); expect(cleanSongTitle('Ordinary Song'), equals('Ordinary Song')); + expect(cleanSongTitle('Bad Guy | Official Music Video'), equals('Bad Guy')); + expect(cleanSongTitle('Hello / 4K Video'), equals('Hello')); + expect(cleanSongTitle("Creepin' feat. The Weeknd"), equals("Creepin'")); }); test('cleanSongArtist removes Topic and featuring suffixes', () { @@ -464,7 +467,7 @@ void main() { ); }); - test('complete unsynced Apple lyrics beat a shorter timed cut', () { + test('timed cut beats complete unsynced Apple lyrics', () { final complete = LyricsResult( lines: [ LyricLine(timeMs: 0, text: 'full chorus ${'na ' * 80}'), @@ -482,11 +485,11 @@ void main() { ); expect(lyricsBodyLength(complete), greaterThan(lyricsBodyLength(truncated))); expect( - LyricsRepository.isBetterCandidate(complete, truncated), + LyricsRepository.isBetterCandidate(truncated, complete), isTrue, ); expect( - LyricsRepository.isBetterCandidate(truncated, complete), + LyricsRepository.isBetterCandidate(complete, truncated), isFalse, ); }); @@ -583,6 +586,8 @@ void main() { test('parenthetical cuts do not steal the main track', () { expect(lyricsTitlesMatch('Song (Interlude)', 'Song'), isFalse); expect(lyricsTitlesMatch('Song', 'Song (Album Version)'), isTrue); + expect(lyricsTitlesMatch('Song (Live)', 'Song'), isFalse); + expect(lyricsTitlesMatch('Song', 'Song (Live)'), isFalse); expect(lyricsTitlesMatch('All Caps', 'All Caps (feat. Doom)'), isTrue); expect( lyricsTitlesMatch('Overdue', 'Overdue (feat. Travis Scott)'), @@ -593,6 +598,12 @@ void main() { test('artist must be the same act, not a cover', () { expect(lyricsArtistsMatch('Madvillain', 'Madvillain'), isTrue); expect(lyricsArtistsMatch('Madvillain', 'Madvillain & MF DOOM'), isTrue); + expect(lyricsArtistsMatch('Drake', 'Drake Bell'), isFalse); + expect(lyricsArtistsMatch('Pink', 'Pink Floyd'), isFalse); + expect(lyricsArtistsMatch('Drake', 'Drake, 21 Savage'), isTrue); + expect(lyricsArtistsMatch('Drake', 'Drake feat. 21 Savage'), isTrue); + expect(lyricsArtistsMatch('Drake', 'Drake ft 21 Savage'), isTrue); + expect(lyricsArtistsMatch('Drake', 'Drake with 21 Savage'), isTrue); expect(lyricsArtistsMatch('Madvillain', 'Abstract Orchestra'), isFalse); expect(lyricsArtistsMatch('Madvillain', 'SERAPHINE NOIR'), isFalse); }); @@ -617,6 +628,14 @@ void main() { expect(lyricsDurationPlausible(132, 138), isTrue); expect(lyricsDurationPlausible(132, 257), isFalse); expect(lyricsDurationPlausible(null, 257), isTrue); + expect(lyricsDurationPlausible(240, 205), isTrue); + expect(lyricsDurationPlausible(240, 280), isTrue); + }); + + test('lyricsTitlesMatch matches Artist - Title against Title', () { + expect(lyricsTitlesMatch('Shape of You', 'Ed Sheeran - Shape Of You'), isTrue); + expect(lyricsTitlesMatch('Ed Sheeran - Shape Of You', 'Shape of You'), isTrue); + expect(lyricsTitlesMatch('Queen - Bohemian Rhapsody', 'Bohemian Rhapsody'), isTrue); }); }); diff --git a/test/lyrics_match_parse_test.dart b/test/lyrics_match_parse_test.dart index 6762661..3c7aea2 100644 --- a/test/lyrics_match_parse_test.dart +++ b/test/lyrics_match_parse_test.dart @@ -105,6 +105,86 @@ void main() { expect(activeLyricLineIndex(lines, 1000), 1); expect(activeLyricLineIndex(lines, 2499), 1); expect(activeLyricLineIndex(lines, 2500), 2); + expect(activeLyricLineIndex(lines, 9999), 2); + expect(activeLyricLineIndex(lines, -100), -1); + + final introLines = [ + const LyricLine(timeMs: 0, durationMs: 500, text: 'intro 1'), + const LyricLine(timeMs: 0, durationMs: 500, text: 'intro 2'), + const LyricLine(timeMs: 3000, durationMs: 1000, text: 'verse 1'), + ]; + expect(activeLyricLineIndex(introLines, 0), 0); + expect(activeLyricLineIndex(introLines, 1500), 0); + expect(activeLyricLineIndex(introLines, 3000), 2); + }); + + test('parseLrc parses mm:ss:xx 3-part timestamps without hour misinterpretation', () { + final lines = parseLrc('[01:23:45]Hello world\n'); + expect(lines.length, 1); + expect(lines.first.timeMs, 83450); // 1m 23s 450ms, NOT 1h 23m 45s (5025000ms) + expect(lines.first.text, 'Hello world'); + }); + + test('parseTtml parses start, startTime, from, to, and suffixed times', () { + const xml = ''' + + +
+

Line 1

+

Line 2

+

Line 3

+
+ +
+'''; + final lines = parseTtml(xml); + expect(lines.length, 3); + expect(lines[0].timeMs, 83450); + expect(lines[1].timeMs, 85500); + expect(lines[2].timeMs, 88000); + }); + + group('Lyrics Accent and Diacritic Matching', () { + test('titles match across accents and diacritics', () { + expect(lyricsTitlesMatchStrict('Beyoncé', 'Beyonce'), isTrue); + expect(lyricsTitlesMatchStrict('Señorita', 'Senorita'), isTrue); + expect(lyricsTitlesMatchStrict('Dépêche Mode', 'Depeche Mode'), isTrue); + expect(lyricsTitlesMatchStrict('Café', 'Cafe'), isTrue); + }); + + test('artists match across accents and diacritics', () { + expect(lyricsArtistsMatchStrict('Beyoncé', 'Beyonce'), isTrue); + expect(lyricsArtistsMatchStrict('Björk', 'Bjork'), isTrue); + expect(lyricsArtistsMatchStrict('Mötley Crüe', 'Motley Crue'), isTrue); + expect(lyricsArtistsMatchStrict('Sigur Rós', 'Sigur Ros'), isTrue); + }); + }); + + group('Lyrics Multi-Artist Billing & Punctuation', () { + test('artists match across conjunctions, commas, &, and x', () { + expect(lyricsArtistsMatchStrict('Calvin Harris, Dua Lipa', 'Calvin Harris & Dua Lipa'), isTrue); + expect(lyricsArtistsMatchStrict('Marshmello x Khalid', 'Marshmello & Khalid'), isTrue); + expect(lyricsArtistsMatchStrict('Post Malone ft. Swae Lee', 'Post Malone, Swae Lee'), isTrue); + expect(lyricsArtistsMatchStrict('Drake', 'Drake, 21 Savage'), isTrue); + expect(lyricsArtistsMatchStrict('Taylor Swift', 'Taylor Swift feat. Bon Iver'), isTrue); + }); + + test('titles match across apostrophe and quote variations', () { + expect(lyricsTitlesMatchStrict("Don't Stop Believin'", 'Dont Stop Believin'), isTrue); + expect(lyricsTitlesMatchStrict("Rock 'N' Roll", 'Rock N Roll'), isTrue); + expect(lyricsTitlesMatchStrict("Ain't No Mountain", 'Aint No Mountain'), isTrue); + }); + + test('rejects similarly named songs to prevent false positives', () { + expect(lyricsTitlesMatchStrict('Let It Be', 'Let It Go'), isFalse); + expect(lyricsTitlesMatchStrict('Bad Guy', 'Bad Girl'), isFalse); + }); + + test('rejects remix without brackets against original studio track', () { + expect(lyricsSameVersion('Despacito', 'Despacito Remix'), isFalse); + expect(lyricsSameVersion('Song', 'Song - VIP Mix'), isFalse); + expect(lyricsSameVersion('Song', 'Song (Acoustic Version)'), isFalse); + }); }); } diff --git a/test/lyrics_provider_pref_test.dart b/test/lyrics_provider_pref_test.dart index 912ddde..61a07c0 100644 --- a/test/lyrics_provider_pref_test.dart +++ b/test/lyrics_provider_pref_test.dart @@ -2,11 +2,27 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lastwave_desktop/features/lyrics/lyrics_providers.dart'; void main() { + test('exactly 17 providers are registered', () { + expect(LyricsProviderId.values.length, 17); + }); + test('provider ids round-trip, unknown falls back to auto', () { + expect(LyricsProviderId.fromId('lastwave'), LyricsProviderId.lastwave); expect(LyricsProviderId.fromId('kugou'), LyricsProviderId.kugou); expect(LyricsProviderId.fromId('lrc_red'), LyricsProviderId.lrcRed); expect( LyricsProviderId.fromId('apple_music'), LyricsProviderId.appleMusic); + expect(LyricsProviderId.fromId('better_lyrics_portato'), + LyricsProviderId.betterLyricsPortato); + expect(LyricsProviderId.fromId('bini_lyrics'), LyricsProviderId.biniLyrics); + expect(LyricsProviderId.fromId('lyrics_plus'), LyricsProviderId.lyricsPlus); + expect(LyricsProviderId.fromId('unison'), LyricsProviderId.unison); + expect(LyricsProviderId.fromId('youtube_captions'), + LyricsProviderId.youtubeCaptions); + expect(LyricsProviderId.fromId('youtube_music'), + LyricsProviderId.youtubeMusic); + expect(LyricsProviderId.fromId('megalobiz'), LyricsProviderId.megalobiz); + expect(LyricsProviderId.fromId('genius'), LyricsProviderId.genius); expect(LyricsProviderId.fromId('bogus'), LyricsProviderId.auto); expect(LyricsProviderId.fromId(null), LyricsProviderId.auto); for (final p in LyricsProviderId.values) { @@ -14,12 +30,23 @@ void main() { } }); - test('only lrclib is a non-word provider; auto is not', () { + test('non-word providers match Android specification', () { + const nonWord = { + LyricsProviderId.auto, + LyricsProviderId.lrclib, + LyricsProviderId.musixmatch, + LyricsProviderId.megalobiz, + LyricsProviderId.genius, + LyricsProviderId.youtubeMusic, + LyricsProviderId.youtubeCaptions, + }; for (final p in LyricsProviderId.values) { - if (p == LyricsProviderId.lrclib || p == LyricsProviderId.auto) { - expect(p.isWordProvider, isFalse); + if (nonWord.contains(p)) { + expect(p.isWordProvider, isFalse, + reason: '${p.id} should not be a word provider'); } else { - expect(p.isWordProvider, isTrue); + expect(p.isWordProvider, isTrue, + reason: '${p.id} should be a word provider'); } } }); @@ -31,4 +58,25 @@ void main() { } expect(LyricsProviderId.auto.id, 'auto'); }); + + test('lyricsSourceToProviderId maps all source variants', () { + expect(lyricsSourceToProviderId('LastWave Karaoke'), 'lastwave'); + expect(lyricsSourceToProviderId('lrc.red (Syllable)'), 'lrc_red'); + expect(lyricsSourceToProviderId('Apple Music'), 'apple_music'); + expect(lyricsSourceToProviderId('BetterLyrics'), 'better_lyrics'); + expect(lyricsSourceToProviderId('QQ Karaoke (Portato)'), + 'better_lyrics_portato'); + expect(lyricsSourceToProviderId('Kugou'), 'kugou'); + expect(lyricsSourceToProviderId('Syllable-Sync (Bini)'), 'bini_lyrics'); + expect(lyricsSourceToProviderId('Video-Match (Word-Sync)'), 'simp_music'); + expect(lyricsSourceToProviderId('Catalog (Musixmatch)'), 'musixmatch'); + expect(lyricsSourceToProviderId('lrclib (synced)'), 'lrclib'); + expect(lyricsSourceToProviderId('LyricsPlus (Syllable)'), 'lyrics_plus'); + expect(lyricsSourceToProviderId('Unison (Word-Sync)'), 'unison'); + expect(lyricsSourceToProviderId('YouTube Captions'), 'youtube_captions'); + expect(lyricsSourceToProviderId('YouTube Music'), 'youtube_music'); + expect(lyricsSourceToProviderId('Megalobiz (LRC)'), 'megalobiz'); + expect(lyricsSourceToProviderId('Genius (Plain)'), 'genius'); + expect(lyricsSourceToProviderId('Unknown source'), isNull); + }); }