323 lines
15 KiB
C#
323 lines
15 KiB
C#
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<SongOverlaySettings> _settings;
|
|
private readonly Func<MediaTrack, CancellationToken, Task<MediaTrack>> _enrich;
|
|
private readonly Action<string, string, Exception?> _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<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, string, Exception?> log)
|
|
{
|
|
_settings = settings;
|
|
_enrich = enrich;
|
|
_log = log;
|
|
}
|
|
|
|
public string Id => "spotify";
|
|
public string DisplayName => "Spotify";
|
|
public event EventHandler<ProviderStateChangedEventArgs>? StateChanged;
|
|
public event EventHandler<string>? 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<MediaSnapshot?> 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<MediaSnapshot?> 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<CoverPayload?> 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<Color>();
|
|
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<Color> 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();
|
|
}
|
|
}
|