using System.Drawing; using System.Drawing.Imaging; using System.Security.Cryptography; using System.Text; using Windows.Media.Control; using Windows.Storage.Streams; namespace Lumi.Companion.SongOverlay.Providers; internal sealed class SpotifyWindowsMediaProvider : IMediaProvider { private const int MaxCoverInputBytes = 3 * 1024 * 1024; private const int MaxCoverOutputBytes = 500 * 1024; private readonly Func _settings; private readonly Func> _enrich; private readonly Action _log; private GlobalSystemMediaTransportControlsSessionManager? _manager; private GlobalSystemMediaTransportControlsSession? _session; private MediaSnapshot? _last; private MediaTrack? _trackCache; private bool _started; private readonly SemaphoreSlim _refreshLock = new(1, 1); public SpotifyWindowsMediaProvider(Func settings, Func> enrich, Action log) { _settings = settings; _enrich = enrich; _log = log; } public string Id => "spotify"; public string DisplayName => "Spotify"; public event EventHandler? StateChanged; public event EventHandler? AvailabilityChanged; public async Task StartAsync(CancellationToken cancellationToken) { if (_started) return; _manager = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync(); _manager.CurrentSessionChanged += OnSessionCollectionChanged; _manager.SessionsChanged += OnSessionCollectionChanged; _started = true; await SelectSessionAsync(cancellationToken).ConfigureAwait(false); } public async Task StopAsync(CancellationToken cancellationToken) { if (!_started) return; if (_manager is not null) { _manager.CurrentSessionChanged -= OnSessionCollectionChanged; _manager.SessionsChanged -= OnSessionCollectionChanged; } DetachSession(); _manager = null; _started = false; await Task.CompletedTask; } public Task GetSnapshotAsync(CancellationToken cancellationToken) => RefreshAsync("snapshot", true, cancellationToken); private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args) { try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); } catch (Exception error) { _log("spotify_session_refresh_failed", "Could not refresh Spotify media sessions", error); } } private async Task SelectSessionAsync(CancellationToken cancellationToken) { var selected = _manager?.GetSessions().FirstOrDefault(IsSpotifySession); if (ReferenceEquals(selected, _session)) { if (_session is not null) await RefreshAndPublishAsync("snapshot", false, cancellationToken).ConfigureAwait(false); return; } DetachSession(); _session = selected; if (_session is null) { AvailabilityChanged?.Invoke(this, "Spotify is not exposing a Windows media session."); if (_last is not null) { _last = null; StateChanged?.Invoke(this, new ProviderStateChangedEventArgs("stop", null)); } return; } _session.MediaPropertiesChanged += OnMediaPropertiesChanged; _session.PlaybackInfoChanged += OnPlaybackInfoChanged; _session.TimelinePropertiesChanged += OnTimelinePropertiesChanged; AvailabilityChanged?.Invoke(this, "Spotify media session connected."); await RefreshAndPublishAsync("snapshot", true, cancellationToken).ConfigureAwait(false); } private void DetachSession() { if (_session is null) return; _session.MediaPropertiesChanged -= OnMediaPropertiesChanged; _session.PlaybackInfoChanged -= OnPlaybackInfoChanged; _session.TimelinePropertiesChanged -= OnTimelinePropertiesChanged; _session = null; _trackCache = null; } private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args) { try { await RefreshAndPublishAsync("media", true, CancellationToken.None).ConfigureAwait(false); } catch (Exception error) { _log("spotify_media_property_update_failed", "Spotify media-property update failed", error); } } private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args) { try { await RefreshAndPublishAsync("playback", false, CancellationToken.None).ConfigureAwait(false); } catch (Exception error) { _log("spotify_playback_update_failed", "Spotify playback update failed", error); } } private async void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args) { try { await RefreshAndPublishAsync("timeline", false, CancellationToken.None).ConfigureAwait(false); } catch (Exception error) { _log("spotify_timeline_update_failed", "Spotify timeline update failed", error); } } private async Task RefreshAndPublishAsync(string reason, bool enrichTrack, CancellationToken cancellationToken) { var snapshot = await RefreshAsync(reason, enrichTrack, cancellationToken).ConfigureAwait(false); if (snapshot is null) return; StateChanged?.Invoke(this, new ProviderStateChangedEventArgs(reason, snapshot)); } private async Task RefreshAsync(string reason, bool enrichTrack, CancellationToken cancellationToken) { if (_session is null) return null; await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { var playback = _session.GetPlaybackInfo(); var timeline = _session.GetTimelineProperties(); var duration = Math.Max(0, (long)timeline.EndTime.TotalMilliseconds); var position = Math.Max(0, (long)timeline.Position.TotalMilliseconds); var status = playback.PlaybackStatus switch { GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing => "playing", GlobalSystemMediaTransportControlsSessionPlaybackStatus.Paused => "paused", GlobalSystemMediaTransportControlsSessionPlaybackStatus.Stopped => "stopped", GlobalSystemMediaTransportControlsSessionPlaybackStatus.Closed => "closed", _ => "unknown" }; var capturedAt = DateTimeOffset.UtcNow; var rate = playback.PlaybackRate ?? 1d; if (rate <= 0) rate = 1d; if (status == "playing" && timeline.LastUpdatedTime != default) { var elapsed = Math.Max(0, (capturedAt - timeline.LastUpdatedTime).TotalMilliseconds); position += (long)(elapsed * rate); if (duration > 0) position = Math.Min(duration, position); } var needsMediaRefresh = _trackCache is null || reason is "media" or "snapshot"; if (needsMediaRefresh) { var media = await _session.TryGetMediaPropertiesAsync(); var title = Clean(media.Title); var artist = Clean(media.Artist); var album = Clean(media.AlbumTitle); if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(artist)) { var key = Fingerprint(title, artist, album, duration); var cover = _settings().SendCoverArt ? await ReadCoverAsync(media.Thumbnail, cancellationToken).ConfigureAwait(false) : null; var fallbackLink = _settings().UseSearchLinkFallback ? "https://open.spotify.com/search/" + Uri.EscapeDataString(string.Join(" ", new[] { title, artist }.Where(value => value.Length > 0))) : ""; var track = new MediaTrack(key, title, artist, album, "", fallbackLink, duration, cover); _trackCache = track; if (enrichTrack) _ = EnrichAndPublishAsync(track); } else { _trackCache = null; } } else if (_trackCache is not null && _trackCache.DurationMilliseconds != duration) { _trackCache = _trackCache with { DurationMilliseconds = duration }; } var snapshot = new MediaSnapshot(Id, status, position, duration, rate, _trackCache, capturedAt); _last = snapshot; return snapshot; } finally { _refreshLock.Release(); } } private async Task EnrichAndPublishAsync(MediaTrack track) { try { var enriched = await _enrich(track, CancellationToken.None).ConfigureAwait(false); if (enriched == track) return; await _refreshLock.WaitAsync().ConfigureAwait(false); try { if (_trackCache?.Key != track.Key) return; _trackCache = enriched; } finally { _refreshLock.Release(); } await RefreshAndPublishAsync("metadata", false, CancellationToken.None).ConfigureAwait(false); } catch (Exception error) { _log("spotify_metadata_update_failed", "Spotify metadata enrichment update failed", error); } } private static bool IsSpotifySession(GlobalSystemMediaTransportControlsSession session) { var source = session.SourceAppUserModelId ?? ""; return source.Contains("spotify", StringComparison.OrdinalIgnoreCase); } private static async Task ReadCoverAsync(IRandomAccessStreamReference? reference, CancellationToken cancellationToken) { if (reference is null) return null; try { using var stream = await reference.OpenReadAsync(); var length = checked((uint)Math.Min(stream.Size, MaxCoverInputBytes)); if (length == 0) return null; using var reader = new DataReader(stream.GetInputStreamAt(0)); var loaded = await reader.LoadAsync(length); cancellationToken.ThrowIfCancellationRequested(); if (loaded == 0) return null; var bytes = new byte[checked((int)loaded)]; reader.ReadBytes(bytes); var mime = string.IsNullOrWhiteSpace(stream.ContentType) ? "image/jpeg" : stream.ContentType; return CreateCoverPayload(bytes, mime); } catch { return null; } } internal static CoverPayload? CreateCoverPayload(byte[] bytes, string sourceMime) { if (bytes.Length == 0 || bytes.Length > MaxCoverInputBytes) return null; try { var encoded = ResizeJpeg(bytes, 512, 82L); if (encoded.Length > MaxCoverOutputBytes) encoded = ResizeJpeg(bytes, 320, 68L); if (encoded.Length == 0 || encoded.Length > MaxCoverOutputBytes) return null; var colors = ExtractColors(encoded); return new CoverPayload("image/jpeg", Convert.ToBase64String(encoded), colors.Primary, colors.Secondary); } catch { return null; } } private static byte[] ResizeJpeg(byte[] bytes, int maximumDimension, long quality) { using var input = new MemoryStream(bytes); using var original = new Bitmap(input); var ratio = Math.Min(1d, Math.Min((double)maximumDimension / original.Width, (double)maximumDimension / original.Height)); var width = Math.Max(1, (int)Math.Round(original.Width * ratio)); var height = Math.Max(1, (int)Math.Round(original.Height * ratio)); using var resized = new Bitmap(width, height, PixelFormat.Format24bppRgb); using (var graphics = Graphics.FromImage(resized)) { graphics.Clear(Color.Black); graphics.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality; graphics.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; graphics.DrawImage(original, 0, 0, width, height); } using var output = new MemoryStream(); var codec = ImageCodecInfo.GetImageEncoders().First(item => item.FormatID == ImageFormat.Jpeg.Guid); using var parameters = new EncoderParameters(1); parameters.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, quality); resized.Save(output, codec, parameters); return output.ToArray(); } internal static (string Primary, string Secondary) ExtractColors(byte[] bytes) { try { using var memory = new MemoryStream(bytes); using var bitmap = new Bitmap(memory); var samples = new List(); var stepX = Math.Max(1, bitmap.Width / 12); var stepY = Math.Max(1, bitmap.Height / 12); for (var y = stepY / 2; y < bitmap.Height; y += stepY) for (var x = stepX / 2; x < bitmap.Width; x += stepX) { var color = bitmap.GetPixel(x, y); if (color.A > 100) samples.Add(color); } if (samples.Count == 0) return ("#1f2937", "#111827"); var primary = Average(samples.OrderByDescending(c => Saturation(c)).Take(Math.Max(1, samples.Count / 3))); var secondary = Average(samples.OrderBy(c => Brightness(c)).Take(Math.Max(1, samples.Count / 3))); return (Hex(primary), Hex(secondary)); } catch { return ("#1f2937", "#111827"); } } private static Color Average(IEnumerable colors) { var list = colors.ToList(); return list.Count == 0 ? Color.FromArgb(31, 41, 55) : Color.FromArgb( (int)list.Average(c => c.R), (int)list.Average(c => c.G), (int)list.Average(c => c.B)); } private static double Saturation(Color c) => c.GetSaturation(); private static double Brightness(Color c) => c.GetBrightness(); private static string Hex(Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}"; private static string Clean(string? value) => string.Join(" ", (value ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).Trim(); private static string Fingerprint(string title, string artist, string album, long duration) { var input = Encoding.UTF8.GetBytes($"{title}\u001f{artist}\u001f{album}\u001f{duration}"); return Convert.ToHexString(SHA256.HashData(input)).ToLowerInvariant(); } public async ValueTask DisposeAsync() { await StopAsync(CancellationToken.None).ConfigureAwait(false); _refreshLock.Dispose(); } }