XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

HaloPixelToolBox

【WinUI3】基于USB HID通讯的花再音响工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Windows.Media.Control;
using XFEExtension.NetCore.WinUIHelper.Utilities.Helper;

namespace HaloPixelToolBox.Client.Utilities;

public class LyricLine
{
    public TimeSpan Timestamp { get; set; }
    public string Text { get; set; } = string.Empty;
}

public static class LrcParser
{
    private static readonly Regex LrcTimeRegex = new Regex(@"\[(\d+):(\d+)(?:[:\.](\d+))?\]", RegexOptions.Compiled);
    private static readonly Regex CleanTimecodeRegex = new Regex(@"\[\d+:\d+(?:[:\.]\d+)?\]", RegexOptions.Compiled);

    public static List<LyricLine> Parse(string lrcContent)
    {
        var lines = new List<LyricLine>();
        if (string.IsNullOrWhiteSpace(lrcContent))
            return lines;

        var rawLines = lrcContent.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries);
        foreach (var line in rawLines)
        {
            var match = LrcTimeRegex.Match(line);
            if (match.Success)
            {
                var minutes = int.Parse(match.Groups[1].Value);
                var seconds = int.Parse(match.Groups[2].Value);
                var fractionStr = match.Groups[3].Value;
                var milliseconds = 0;
                if (!string.IsNullOrEmpty(fractionStr))
                {
                    if (fractionStr.Length == 2)
                        milliseconds = int.Parse(fractionStr) * 10;
                    else if (fractionStr.Length == 3)
                        milliseconds = int.Parse(fractionStr);
                    else
                        milliseconds = int.Parse(fractionStr.Substring(0, 3));
                }

                var timestamp = new TimeSpan(0, 0, minutes, seconds, milliseconds);
                var text = line.Substring(match.Length).Trim();

                // Strip any remaining timecodes from the lyric text (e.g. "[00:00.00]" or "[00:00:000]")
                text = CleanTimecodeRegex.Replace(text, "").Trim();

                if (!string.IsNullOrEmpty(text))
                {
                    lines.Add(new LyricLine { Timestamp = timestamp, Text = text });
                }
            }
        }
        lines.Sort((a, b) => a.Timestamp.CompareTo(b.Timestamp));
        return lines;
    }
}

public class LrclibResponse
{
    [JsonPropertyName("plainLyrics")]
    public string? PlainLyrics { get; set; }

    [JsonPropertyName("syncedLyrics")]
    public string? SyncedLyrics { get; set; }

    [JsonPropertyName("trackName")]
    public string? TrackName { get; set; }

    [JsonPropertyName("artistName")]
    public string? ArtistName { get; set; }
}

public class SpotifyLyricsReader
{
    private static HttpClient? _httpClient;
    private static readonly object HttpLock = new object();

    private static HttpClient HttpClient
    {
        get
        {
            if (_httpClient == null)
            {
                lock (HttpLock)
                {
                    if (_httpClient == null)
                    {
                        _httpClient = CreateHttpClient();
                    }
                }
            }
            return _httpClient;
        }
    }

    private GlobalSystemMediaTransportControlsSessionManager? _manager;
    private GlobalSystemMediaTransportControlsSession? _currentSession;
    private bool _isInitialized = false;
    private CancellationTokenSource? _cts;

    private string _currentTitle = string.Empty;
    private string _currentArtist = string.Empty;
    private double _currentDuration = 0;
    private List<LyricLine> _lyricLines = new();

    private DateTimeOffset _songStartTime = DateTimeOffset.Now;
    private bool _isSmtcSynced = false;
    private string _lastColorTitle = string.Empty;

    public (byte R, byte G, byte B)? CurrentAlbumColor { get; private set; }

    public bool IsPlaying
    {
        get
        {
            if (_currentSession != null)
            {
                try
                {
                    var playback = _currentSession.GetPlaybackInfo();
                    return playback.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing;
                }
                catch { }
            }
            return false;
        }
    }

    public static async Task<(byte R, byte G, byte B)?> GetDominantColorAsync(Windows.Storage.Streams.IRandomAccessStreamReference thumbnail)
    {
        try
        {
            using var stream = await thumbnail.OpenReadAsync();
            var decoder = await Windows.Graphics.Imaging.BitmapDecoder.CreateAsync(stream);
            var transform = new Windows.Graphics.Imaging.BitmapTransform
            {
                ScaledWidth = 16,
                ScaledHeight = 16,
                InterpolationMode = Windows.Graphics.Imaging.BitmapInterpolationMode.Linear
            };
            var pixelData = await decoder.GetPixelDataAsync(
                Windows.Graphics.Imaging.BitmapPixelFormat.Rgba8,
                Windows.Graphics.Imaging.BitmapAlphaMode.Ignore,
                transform,
                Windows.Graphics.Imaging.ExifOrientationMode.IgnoreExifOrientation,
                Windows.Graphics.Imaging.ColorManagementMode.ColorManageToSRgb
            );
            byte[] pixels = pixelData.DetachPixelData();
            long rSum = 0, gSum = 0, bSum = 0;
            int pixelCount = pixels.Length / 4;
            for (int i = 0; i < pixels.Length; i += 4)
            {
                rSum += pixels[i];
                gSum += pixels[i + 1];
                bSum += pixels[i + 2];
            }
            if (pixelCount > 0)
            {
                return ((byte)(rSum / pixelCount), (byte)(gSum / pixelCount), (byte)(bSum / pixelCount));
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[ERROR] GetDominantColorAsync failed: {ex.Message}\n{ex.StackTrace}");
        }
        return null;
    }

    public string CurrentTitle => _currentTitle;
    public string CurrentArtist => _currentArtist;

    private static HttpClient CreateHttpClient()
    {
        var handler = new HttpClientHandler
        {
            // Ignore certificate errors to bypass any MITM issues caused by local proxy clients decrypting HTTPS
            ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
        };

        // List of common local proxy ports in China:
        // 7890: Clash / Mihomo
        // 10809: v2rayN / Xray
        // 10808: Shadowsocks / SSR
        // 7898: Clash alternative
        int[] commonPorts = { 7890, 10809, 10808, 7898 };
        bool proxyConfigured = false;

        foreach (var port in commonPorts)
        {
            if (IsPortOpen(port))
            {
                handler.Proxy = new System.Net.WebProxy($"http://127.0.0.1:{port}");
                handler.UseProxy = true;
                proxyConfigured = true;
                break;
            }
        }

        var client = new HttpClient(handler);
        client.DefaultRequestHeaders.UserAgent.Clear();
        client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");

        if (proxyConfigured)
        {
            Console.WriteLine("Auto-configured HttpClient to use local proxy for accelerated connection.");
        }
        else
        {
            Console.WriteLine("No local proxy detected, using direct system default connection.");
        }

        return client;
    }

    private static bool IsPortOpen(int port)
    {
        try
        {
            using var client = new System.Net.Sockets.TcpClient();
            var connectTask = client.ConnectAsync(IPAddress.Loopback, port);
            if (connectTask.Wait(250)) // Wait up to 250ms to ensure JIT doesn't miss the handshake
            {
                return client.Connected;
            }
        }
        catch { }
        return false;
    }

    private static async Task<T?> RunWithTimeout<T>(Windows.Foundation.IAsyncOperation<T> asyncOp, int timeoutMs)
    {
        using var cts = new CancellationTokenSource();
        try
        {
            var task = asyncOp.AsTask(cts.Token);
            var delayTask = Task.Delay(timeoutMs);
            var completedTask = await Task.WhenAny(task, delayTask);
            if (completedTask == task)
            {
                return await task;
            }
            else
            {
                cts.Cancel(); // Cancel the WinRT operation
                Console.WriteLine("WinRT async operation timed out.");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"RunWithTimeout failed: {ex.Message}");
        }
        return default;
    }

    public bool Initialize()
    {
        Console.WriteLine($"Initialize called. isInitialized={_isInitialized}");
        if (_isInitialized)
        {
            UpdateCurrentSession();
            return _currentSession != null || Process.GetProcessesByName("Spotify").Length > 0;
        }

        try
        {
            var task = Task.Run(async () =>
            {
                _manager = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
                _manager.SessionsChanged += OnSessionsChanged;
                _isInitialized = true;
                UpdateCurrentSession();
                StartWindowTitlePolling();
                return _currentSession != null || Process.GetProcessesByName("Spotify").Length > 0;
            });
            return task.Result;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"SpotifyLyricsReader Initialize failed: {ex.Message}");
            return false;
        }
    }

    private void OnSessionsChanged(GlobalSystemMediaTransportControlsSessionManager sender, SessionsChangedEventArgs args)
    {
        Console.WriteLine("OnSessionsChanged event fired.");
        UpdateCurrentSession();
    }

    private void UpdateCurrentSession()
    {
        if (_manager == null) return;

        var sessions = _manager.GetSessions();
        GlobalSystemMediaTransportControlsSession? spotifySession = null;
        foreach (var s in sessions)
        {
            if (s.SourceAppUserModelId.Contains("Spotify", StringComparison.OrdinalIgnoreCase))
            {
                spotifySession = s;
                break;
            }
        }

        if (spotifySession != _currentSession)
        {
            Console.WriteLine($"Active session changed. Old: {_currentSession?.SourceAppUserModelId ?? "null"}, New: {spotifySession?.SourceAppUserModelId ?? "null"}");
            if (_currentSession != null)
            {
                _currentSession.MediaPropertiesChanged -= OnMediaPropertiesChanged;
                _currentSession.PlaybackInfoChanged -= OnPlaybackInfoChanged;
                _currentSession.TimelinePropertiesChanged -= OnTimelinePropertiesChanged;
            }

            _currentSession = spotifySession;

            if (_currentSession != null)
            {
                _currentSession.MediaPropertiesChanged += OnMediaPropertiesChanged;
                _currentSession.PlaybackInfoChanged += OnPlaybackInfoChanged;
                _currentSession.TimelinePropertiesChanged += OnTimelinePropertiesChanged;
                _ = SyncWithSmtcAsync();
            }
        }
    }

    private void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
    {
        Console.WriteLine("OnMediaPropertiesChanged event fired.");
        _ = SyncWithSmtcAsync();
    }

    private void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args)
    {
        Console.WriteLine("OnPlaybackInfoChanged event fired.");
        _ = SyncWithSmtcAsync();
    }

    private void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args)
    {
        Console.WriteLine("OnTimelinePropertiesChanged event fired.");
        _ = SyncWithSmtcAsync();
    }

    private async Task SyncWithSmtcAsync()
    {
        if (_currentSession == null) return;

        try
        {
            var media = await RunWithTimeout(_currentSession.TryGetMediaPropertiesAsync(), 500);
            if (media != null && !string.IsNullOrEmpty(media.Title))
            {
                // Check if SMTC matches our window-title song
                if (media.Title.Contains(_currentTitle, StringComparison.OrdinalIgnoreCase) ||
                    _currentTitle.Contains(media.Title, StringComparison.OrdinalIgnoreCase))
                {
                    var timeline = _currentSession.GetTimelineProperties();
                    _currentDuration = timeline?.EndTime.TotalSeconds ?? 0;
                    _isSmtcSynced = true;
                    Console.WriteLine($"SMTC Sync Achieved for: {_currentArtist} - {_currentTitle} (Duration: {_currentDuration}s)");

                     if (media.Thumbnail != null)
                     {
                         if (_lastColorTitle != media.Title)
                         {
                             _lastColorTitle = media.Title;
                             var color = await GetDominantColorAsync(media.Thumbnail);
                             if (color.HasValue)
                             {
                                 CurrentAlbumColor = color.Value;
                                 Console.WriteLine($"Extracted album dominant color: RGB({color.Value.R}, {color.Value.G}, {color.Value.B})");
                             }
                             else
                             {
                                 Console.WriteLine("GetDominantColorAsync returned null for the thumbnail.");
                             }
                         }
                     }
                     else
                     {
                         Console.WriteLine("media.Thumbnail is null for this track.");
                     }
                }
                else
                {
                    Console.WriteLine($"SMTC song mismatch. SMTC Title: {media.Title}, Current Title: {_currentTitle}");
                }
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"SyncWithSmtcAsync failed: {ex.Message}");
        }
    }

    private void StartWindowTitlePolling()
    {
        Console.WriteLine("Starting window title polling loop.");
        Task.Run(async () =>
        {
            while (true)
            {
                try
                {
                    var trackInfo = await GetTrackInfoAsync();

                    if (!string.IsNullOrEmpty(trackInfo.Title))
                    {
                        if (trackInfo.Artist != _currentArtist || trackInfo.Title != _currentTitle)
                        {
                            Console.WriteLine($"Track change detected via polling: {trackInfo.Artist} - {trackInfo.Title}");
                            _ = HandleTrackChangeAsync(trackInfo.Artist, trackInfo.Title);
                        }
                    }
                    else
                    {
                        if (!string.IsNullOrEmpty(_currentTitle))
                        {
                            Console.WriteLine("No active track detected, clearing lyrics.");
                            _currentTitle = string.Empty;
                            _currentArtist = string.Empty;
                            _lyricLines.Clear();
                        }
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"Track polling loop error: {ex.Message}");
                }
                await Task.Delay(250);
            }
        });
    }

    private static string GetSpotifyWindowTitle()
    {
        var processes = Process.GetProcessesByName("Spotify");
        foreach (var p in processes)
        {
            try
            {
                if (!string.IsNullOrEmpty(p.MainWindowTitle))
                {
                    return p.MainWindowTitle;
                }
            }
            catch { /* Ignore process access errors */ }
        }
        return string.Empty;
    }

    private async Task<(string Artist, string Title)> GetTrackInfoAsync()
    {
        string artist = string.Empty;
        string title = string.Empty;

        // 1. Try Window Title for instant song metadata
        string winTitle = GetSpotifyWindowTitle();
        if (!string.IsNullOrEmpty(winTitle) && winTitle != "Spotify" && winTitle != "Spotify Premium" && winTitle != "Spotify Free")
        {
            int dashIndex = winTitle.IndexOf(" - ");
            if (dashIndex > 0)
            {
                artist = winTitle.Substring(0, dashIndex).Trim();
                title = winTitle.Substring(dashIndex + 3).Trim();
            }
        }

        // 2. Fallback to SMTC metadata if window title is empty (e.g. minimized to tray)
        if (string.IsNullOrEmpty(title) && _currentSession != null)
        {
            try
            {
                var media = await RunWithTimeout(_currentSession.TryGetMediaPropertiesAsync(), 500);
                if (media != null && !string.IsNullOrEmpty(media.Title))
                {
                    artist = media.Artist;
                    title = media.Title;
                }
            }
            catch { }
        }

        return (artist, title);
    }

    private static string SanitizeFileName(string name)
    {
        var invalidChars = Path.GetInvalidFileNameChars();
        var cleanName = string.Join("_", name.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)).Trim();
        return cleanName;
    }

    private async Task HandleTrackChangeAsync(string artist, string track)
    {
        Console.WriteLine($"HandleTrackChangeAsync started. Target: {artist} - {track}");
        _cts?.Cancel();
        _cts = new CancellationTokenSource();
        var token = _cts.Token;

        try
        {
            _currentTitle = track;
            _currentArtist = artist;
            _songStartTime = DateTimeOffset.Now;
            _currentDuration = 0;
            _isSmtcSynced = false;
            _lyricLines.Clear();

            // Await SMTC synchronization to get the duration before requesting lyrics
            await SyncWithSmtcAsync();

            Console.WriteLine($"Fetching lyrics for: {_currentArtist} - {_currentTitle} (Duration: {_currentDuration}s)");

            // 1. Check local cache first
            string lyricsCacheDir = Path.Combine(AppPathHelper.AppCache, "Lyrics");
            if (!Directory.Exists(lyricsCacheDir))
            {
                Directory.CreateDirectory(lyricsCacheDir);
            }
            string cacheFilePath = Path.Combine(lyricsCacheDir, $"{SanitizeFileName(_currentArtist)} - {SanitizeFileName(_currentTitle)}.lrc");

            if (File.Exists(cacheFilePath))
            {
                try
                {
                    string cachedLrc = await File.ReadAllTextAsync(cacheFilePath, token);
                    if (!string.IsNullOrEmpty(cachedLrc))
                    {
                        _lyricLines = LrcParser.Parse(cachedLrc);
                        Console.WriteLine($"Loaded lyrics from local cache file: '{cacheFilePath}'");
                        return;
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"Failed to read cached lyrics file: {ex.Message}");
                }
            }

            // 2. Local cache miss: query LRCLIB database (with local proxy acceleration enabled)
            var lyricsRes = await FetchLyricsAsync(_currentTitle, _currentArtist, _currentDuration, token);

            if (token.IsCancellationRequested)
            {
                Console.WriteLine($"Lyrics fetch cancelled for: {artist} - {track}");
                return;
            }

            if (lyricsRes != null && !string.IsNullOrEmpty(lyricsRes.SyncedLyrics))
            {
                _lyricLines = LrcParser.Parse(lyricsRes.SyncedLyrics);
                Console.WriteLine($"Loaded {_lyricLines.Count} synced lines from LRCLIB.");

                // Save to local cache asynchronously
                try
                {
                    await File.WriteAllTextAsync(cacheFilePath, lyricsRes.SyncedLyrics, token);
                    Console.WriteLine($"Saved fetched lyrics to local cache: '{cacheFilePath}'");
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"Failed to save lyrics to local cache: {ex.Message}");
                }
            }
            else if (lyricsRes != null && !string.IsNullOrEmpty(lyricsRes.PlainLyrics))
            {
                Console.WriteLine("Synced lyrics not found, plain lyrics available.");
            }
            else
            {
                Console.WriteLine("No lyrics found.");
            }
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("Fetch cancelled (OperationCanceledException).");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"HandleTrackChangeAsync failed: {ex.Message}");
        }
    }

    private static HashSet<string> GetSignificantWords(string input)
    {
        var words = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        if (string.IsNullOrEmpty(input)) return words;

        string cleaned = Regex.Replace(input, @"[^\w\s]", " ");
        var rawWords = cleaned.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);

        var stopWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "with", "by",
            "feat", "ft", "version", "mix", "remix", "radio", "edit", "acoustic", "live", "original",
            "sped", "up", "slowed", "down"
        };

        foreach (var w in rawWords)
        {
            if (w.Length > 1 && !stopWords.Contains(w))
            {
                words.Add(w);
            }
        }
        return words;
    }

    private bool IsLooseMatch(string returnedTitle, string returnedArtist, string targetTitle, string targetArtist)
    {
        var targetTitleWords = GetSignificantWords(targetTitle);
        var returnedTitleWords = GetSignificantWords(returnedTitle);

        bool titleMatches = false;
        foreach (var w in targetTitleWords)
        {
            if (returnedTitleWords.Contains(w))
            {
                titleMatches = true;
                break;
            }
        }

        if (targetTitleWords.Count == 0 || !titleMatches)
        {
            titleMatches = returnedTitle.Contains(targetTitle, StringComparison.OrdinalIgnoreCase) ||
                           targetTitle.Contains(returnedTitle, StringComparison.OrdinalIgnoreCase);
        }

        var targetArtistWords = GetSignificantWords(targetArtist);
        var returnedArtistWords = GetSignificantWords(returnedArtist);

        bool artistMatches = false;
        foreach (var w in targetArtistWords)
        {
            if (returnedArtistWords.Contains(w))
            {
                artistMatches = true;
                break;
            }
        }

        if (targetArtistWords.Count == 0 || !artistMatches)
        {
            artistMatches = returnedArtist.Contains(targetArtist, StringComparison.OrdinalIgnoreCase) ||
                            targetArtist.Contains(returnedArtist, StringComparison.OrdinalIgnoreCase);
        }

        return titleMatches && artistMatches;
    }

    private bool IsLrcLibMatch(LrclibResponse result, string targetArtist, string targetTitle)
    {
        if (result == null) return false;
        string trackName = result.TrackName ?? string.Empty;
        string artistName = result.ArtistName ?? string.Empty;

        bool match = IsLooseMatch(trackName, artistName, targetTitle, targetArtist);
        Console.WriteLine($"IsLrcLibMatch: Returned Title: '{trackName}', Artist: '{artistName}' vs Target Title: '{targetTitle}', Artist: '{targetArtist}' => MATCH={match}");
        return match;
    }

    private async Task<LrclibResponse?> FetchLyricsAsync(string title, string artist, double duration, CancellationToken token)
    {
        // LRCLIB is the fallback open-source database.
        // We query each endpoint with a separate 15-second timeout to handle high-latency connections robustly.
        try
        {
            // 1. Try real-time get API first if duration is available
            if (duration > 0)
            {
                using var getCts = CancellationTokenSource.CreateLinkedTokenSource(token);
                getCts.CancelAfter(TimeSpan.FromSeconds(15));

                int durationSeconds = (int)Math.Round(duration);
                string getUrl = $"https://lrclib.net/api/get?track_name={Uri.EscapeDataString(title)}&artist_name={Uri.EscapeDataString(artist)}&duration={durationSeconds}";
                Console.WriteLine($"LRCLIB /api/get lookup URL: '{getUrl}'");

                try
                {
                    var getResponse = await HttpClient.GetAsync(getUrl, getCts.Token);
                    if (getResponse.IsSuccessStatusCode)
                    {
                        var getRes = await getResponse.Content.ReadFromJsonAsync<LrclibResponse>(cancellationToken: getCts.Token);
                        if (getRes != null && (!string.IsNullOrEmpty(getRes.SyncedLyrics) || !string.IsNullOrEmpty(getRes.PlainLyrics)))
                        {
                            Console.WriteLine("LRCLIB /api/get lyrics retrieved successfully.");
                            return getRes;
                        }
                    }
                    else
                    {
                        Console.WriteLine($"LRCLIB /api/get returned status code: {getResponse.StatusCode}");
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"LRCLIB /api/get failed: {ex.Message}");
                }
            }

            if (token.IsCancellationRequested) return null;

            // 2. Try get-cached (precise cache lookup)
            {
                using var cachedCts = CancellationTokenSource.CreateLinkedTokenSource(token);
                cachedCts.CancelAfter(TimeSpan.FromSeconds(15));

                string cachedUrl = $"https://lrclib.net/api/get-cached?track_name={Uri.EscapeDataString(title)}&artist_name={Uri.EscapeDataString(artist)}";
                Console.WriteLine($"LRCLIB cached lookup URL: '{cachedUrl}'");

                try
                {
                    var response = await HttpClient.GetAsync(cachedUrl, cachedCts.Token);
                    if (response.IsSuccessStatusCode)
                    {
                        var cachedRes = await response.Content.ReadFromJsonAsync<LrclibResponse>(cancellationToken: cachedCts.Token);
                        if (cachedRes != null && (!string.IsNullOrEmpty(cachedRes.SyncedLyrics) || !string.IsNullOrEmpty(cachedRes.PlainLyrics)))
                        {
                            Console.WriteLine("LRCLIB cached lyrics retrieved successfully.");
                            return cachedRes;
                        }
                    }
                    else
                    {
                        Console.WriteLine($"LRCLIB cached API returned status code: {response.StatusCode}");
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"LRCLIB cached API failed: {ex.Message}");
                }
            }

            if (token.IsCancellationRequested) return null;

            // 3. Try precise search endpoint (exact metadata match)
            {
                using var searchCts = CancellationTokenSource.CreateLinkedTokenSource(token);
                searchCts.CancelAfter(TimeSpan.FromSeconds(15));

                string searchUrl = $"https://lrclib.net/api/search?track_name={Uri.EscapeDataString(title)}&artist_name={Uri.EscapeDataString(artist)}";
                Console.WriteLine($"LRCLIB precise search URL: '{searchUrl}'");

                try
                {
                    var searchResponse = await HttpClient.GetAsync(searchUrl, searchCts.Token);
                    if (searchResponse.IsSuccessStatusCode)
                    {
                        var searchResults = await searchResponse.Content.ReadFromJsonAsync<List<LrclibResponse>>(cancellationToken: searchCts.Token);
                        if (searchResults != null && searchResults.Count > 0)
                        {
                            Console.WriteLine($"LRCLIB precise search returned {searchResults.Count} results.");
                            foreach (var result in searchResults)
                            {
                                if (!string.IsNullOrEmpty(result.SyncedLyrics) && IsLrcLibMatch(result, artist, title))
                                {
                                    Console.WriteLine("LRCLIB matched precise search result.");
                                    return result;
                                }
                            }
                        }
                    }
                    else
                    {
                        Console.WriteLine($"LRCLIB precise search returned status code: {searchResponse.StatusCode}");
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"LRCLIB precise search failed: {ex.Message}");
                }
            }

            if (token.IsCancellationRequested) return null;

            // 4. Fallback to cleaned metadata search
            string cleanTitle = Regex.Replace(title, @"\s*[\(\[][^\)\]]*[\)\]]", "");
            cleanTitle = Regex.Replace(cleanTitle, @"\s*-\s*.*?(?:radio|edit|remaster|remix|mix|acoustic|live|version|sped|slowed|instrumental).*", "", RegexOptions.IgnoreCase);
            cleanTitle = Regex.Replace(cleanTitle, @"\s+(?:feat|ft)\.?\s+.*", "", RegexOptions.IgnoreCase);

            string cleanArtist = Regex.Replace(artist, @"\s+(?:feat|ft)\.?\s+.*", "", RegexOptions.IgnoreCase);
            int commaIndex = cleanArtist.IndexOfAny(new[] { ',', ';', '/' });
            if (commaIndex > 0)
            {
                cleanArtist = cleanArtist.Substring(0, commaIndex).Trim();
            }

            cleanTitle = cleanTitle.Trim();
            cleanArtist = cleanArtist.Trim();

            if (cleanTitle != title || cleanArtist != artist)
            {
                using var fallbackCts = CancellationTokenSource.CreateLinkedTokenSource(token);
                fallbackCts.CancelAfter(TimeSpan.FromSeconds(15));

                string fallbackUrl = $"https://lrclib.net/api/search?track_name={Uri.EscapeDataString(cleanTitle)}&artist_name={Uri.EscapeDataString(cleanArtist)}";
                Console.WriteLine($"LRCLIB clean fallback search URL: '{fallbackUrl}'");

                try
                {
                    var fallbackResponse = await HttpClient.GetAsync(fallbackUrl, fallbackCts.Token);
                    if (fallbackResponse.IsSuccessStatusCode)
                    {
                        var fallbackResults = await fallbackResponse.Content.ReadFromJsonAsync<List<LrclibResponse>>(cancellationToken: fallbackCts.Token);
                        if (fallbackResults != null && fallbackResults.Count > 0)
                        {
                            Console.WriteLine($"LRCLIB fallback search returned {fallbackResults.Count} results.");
                            foreach (var result in fallbackResults)
                            {
                                if (!string.IsNullOrEmpty(result.SyncedLyrics) && IsLrcLibMatch(result, artist, title))
                                {
                                    Console.WriteLine("LRCLIB matched clean fallback search result.");
                                    return result;
                                }
                            }
                        }
                    }
                    else
                    {
                        Console.WriteLine($"LRCLIB fallback search returned status code: {fallbackResponse.StatusCode}");
                    }
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"LRCLIB fallback search failed: {ex.Message}");
                }
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"LRCLIB lyric fetch outer error: {ex.Message}");
        }

        return null;
    }

    public bool TryReadLyrics(out string lyrics)
    {
        lyrics = string.Empty;

        if (_lyricLines == null || _lyricLines.Count == 0)
        {
            if (!string.IsNullOrWhiteSpace(_currentTitle))
            {
                lyrics = $"{_currentArtist} - {_currentTitle}";
                return true;
            }
            lyrics = "无歌词信息";
            return false;
        }

        TimeSpan currentPosition = TimeSpan.Zero;
        bool positionDetermined = false;

        // Try using SMTC for progress if synced
        if (_isSmtcSynced && _currentSession != null)
        {
            try
            {
                var playback = _currentSession.GetPlaybackInfo();
                var timeline = _currentSession.GetTimelineProperties();
                if (timeline != null && playback.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing)
                {
                    var elapsed = DateTimeOffset.Now - timeline.LastUpdatedTime;
                    if (elapsed < TimeSpan.Zero) elapsed = TimeSpan.Zero;
                    currentPosition = timeline.Position + elapsed;
                    positionDetermined = true;
                }
                else if (timeline != null && playback.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Paused)
                {
                    currentPosition = timeline.Position;
                    positionDetermined = true;
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"TryReadLyrics SMTC position error: {ex.Message}");
            }
        }

        // Fallback to local timer if SMTC not synced or failed
        if (!positionDetermined)
        {
            var elapsed = DateTimeOffset.Now - _songStartTime;
            if (elapsed < TimeSpan.Zero) elapsed = TimeSpan.Zero;
            currentPosition = elapsed;
        }

        string currentText = string.Empty;
        foreach (var line in _lyricLines)
        {
            if (line.Timestamp <= currentPosition)
            {
                currentText = line.Text;
            }
            else
            {
                break;
            }
        }

        if (string.IsNullOrEmpty(currentText))
        {
            lyrics = $"{_currentArtist} - {_currentTitle}";
        }
        else
        {
            lyrics = currentText;
        }

        return true;
    }
}