using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security;
using System.Security.Permissions;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BepInEx;
using BepInEx.Configuration;
using BepInEx.Logging;
using DG.Tweening;
using DG.Tweening.Core;
using DG.Tweening.Plugins.Options;
using HarmonyLib;
using Microsoft.CodeAnalysis;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]
[assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")]
[assembly: AssemblyCompany("WKMusic")]
[assembly: AssemblyConfiguration("Release")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyInformationalVersion("1.0.0+57e91829fcde28c0bc52c8f4a67e18ff9a2b5a5b")]
[assembly: AssemblyProduct("WKMusic")]
[assembly: AssemblyTitle("WKMusic")]
[assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)]
[assembly: AssemblyVersion("1.0.0.0")]
[module: UnverifiableCode]
[module: RefSafetyRules(11)]
namespace Microsoft.CodeAnalysis
{
[CompilerGenerated]
[Microsoft.CodeAnalysis.Embedded]
internal sealed class EmbeddedAttribute : Attribute
{
}
}
namespace System.Runtime.CompilerServices
{
[CompilerGenerated]
[Microsoft.CodeAnalysis.Embedded]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Parameter | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter, AllowMultiple = false, Inherited = false)]
internal sealed class NullableAttribute : Attribute
{
public readonly byte[] NullableFlags;
public NullableAttribute(byte P_0)
{
NullableFlags = new byte[1] { P_0 };
}
public NullableAttribute(byte[] P_0)
{
NullableFlags = P_0;
}
}
[CompilerGenerated]
[Microsoft.CodeAnalysis.Embedded]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Delegate, AllowMultiple = false, Inherited = false)]
internal sealed class NullableContextAttribute : Attribute
{
public readonly byte Flag;
public NullableContextAttribute(byte P_0)
{
Flag = P_0;
}
}
[CompilerGenerated]
[Microsoft.CodeAnalysis.Embedded]
[AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)]
internal sealed class RefSafetyRulesAttribute : Attribute
{
public readonly int Version;
public RefSafetyRulesAttribute(int P_0)
{
Version = P_0;
}
}
internal static class IsExternalInit
{
}
}
namespace WKMusic
{
internal static class BrowserSetup
{
public sealed record SetupResult(string ClientId, string ClientSecret, SpotifyToken Token);
private sealed class FlowState
{
public string? CredId;
public string? CredSecret;
public string? OAuthState;
public Func<string, string, string, Task<SpotifyToken>>? ExchangeCode;
}
private static readonly HttpClient _http = new HttpClient();
private static readonly string[] Scopes = new string[3] { "user-read-playback-state", "user-modify-playback-state", "user-read-currently-playing" };
private const string BaseStyle = "\n<style>\n body{font-family:'Segoe UI',sans-serif;background:#121212;color:#fff;display:flex;\n align-items:center;justify-content:center;min-height:100vh;margin:0}\n .card{background:#1e1e1e;border-radius:12px;padding:36px 40px;width:400px;\n box-shadow:0 8px 32px #0008}\n h1{margin:0 0 6px;font-size:1.4rem}\n .sub{color:#888;font-size:.9rem;margin:0 0 24px}\n .err{color:#e74c3c;font-size:.85rem;margin:0 0 14px}\n label{display:block;font-size:.85rem;color:#aaa;margin-bottom:4px}\n input{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:6px;\n border:1px solid #333;background:#2a2a2a;color:#fff;font-size:.95rem;margin-bottom:16px}\n input:focus{outline:none;border-color:#1db954}\n button{width:100%;padding:11px;background:#1db954;color:#000;font-weight:700;\n border:none;border-radius:6px;cursor:pointer;font-size:1rem}\n button:hover{background:#1ed760}\n .hint{font-size:.8rem;color:#666;margin-top:18px;line-height:1.6}\n a{color:#1db954}\n code{background:#2a2a2a;padding:1px 5px;border-radius:3px;font-size:.78rem}\n</style>";
public static async Task<SetupResult?> RunAsync(string workerUrl, Func<string, string, string, Task<SpotifyToken>> exchangeCode, string initialClientId = "", string initialClientSecret = "", CancellationToken ct = default(CancellationToken))
{
int port = FindFreePort();
string redirectUri = workerUrl + "/callback";
string credId = NullIfEmpty(initialClientId);
string credSecret = NullIfEmpty(initialClientSecret);
TaskCompletionSource<SetupResult?> tcs = new TaskCompletionSource<SetupResult>();
ct.Register(delegate
{
tcs.TrySetResult(null);
});
HttpListener listener = new HttpListener();
listener.Prefixes.Add($"http://localhost:{port}/");
listener.Start();
FlowState state = new FlowState
{
CredId = credId,
CredSecret = credSecret,
ExchangeCode = exchangeCode
};
Task.Run(async delegate
{
try
{
while (!tcs.Task.IsCompleted)
{
HttpListenerContext ctx;
try
{
ctx = await listener.GetContextAsync();
}
catch
{
break;
}
Task.Run(() => Handle(ctx, port, redirectUri, state, tcs));
}
}
catch
{
}
finally
{
listener.Stop();
}
});
OpenBrowser($"http://localhost:{port}/");
Plugin.Logger.LogInfo((object)$"WKMusic: setup → http://localhost:{port}/");
return await tcs.Task;
}
private static async Task Handle(HttpListenerContext ctx, int port, string redirectUri, FlowState s, TaskCompletionSource<SetupResult?> tcs)
{
HttpListenerRequest request = ctx.Request;
HttpListenerResponse res = ctx.Response;
string absolutePath = request.Url.AbsolutePath;
try
{
if (request.HttpMethod == "GET" && absolutePath == "/")
{
if (s.CredId == null || s.CredSecret == null)
{
await WriteHtml(res, PageCredentials());
return;
}
s.OAuthState = $"{Guid.NewGuid():N}:{port}";
Redirect(res, BuildAuthUrl(s.CredId, s.OAuthState, redirectUri));
}
else if (request.HttpMethod == "POST" && absolutePath == "/submit")
{
Dictionary<string, string> obj = await ReadForm(request);
obj.TryGetValue("clientId", out var id2);
obj.TryGetValue("clientSecret", out var secret2);
id2 = id2?.Trim() ?? "";
secret2 = secret2?.Trim() ?? "";
string text = ValidateCredentials(id2, secret2);
if (text == null)
{
text = await VerifyWithSpotifyAsync(id2, secret2);
}
string text2 = text;
if (text2 != null)
{
await WriteHtml(res, PageCredentials(id2, text2));
return;
}
s.CredId = id2;
s.CredSecret = secret2;
s.OAuthState = $"{Guid.NewGuid():N}:{port}";
Redirect(res, BuildAuthUrl(s.CredId, s.OAuthState, redirectUri));
}
else if (request.HttpMethod == "GET" && absolutePath == "/callback")
{
NameValueCollection queryString = request.QueryString;
string text3 = queryString["code"];
string text4 = queryString["state"];
string text5 = queryString["error"];
if (!string.IsNullOrEmpty(text5))
{
await WriteHtml(res, PageError("Spotify error: " + text5));
tcs.TrySetResult(null);
}
else if (text4 != s.OAuthState)
{
await WriteHtml(res, PageError("State mismatch — please try again."));
tcs.TrySetResult(null);
}
else if (!string.IsNullOrEmpty(text3) && s.CredId != null && s.CredSecret != null && s.ExchangeCode != null)
{
try
{
SpotifyToken token = await s.ExchangeCode(s.CredId, s.CredSecret, text3);
await WriteHtml(res, PageSuccess());
tcs.TrySetResult(new SetupResult(s.CredId, s.CredSecret, token));
}
catch (Exception ex)
{
await WriteHtml(res, PageError("Token exchange failed: " + ex.Message));
tcs.TrySetResult(null);
}
}
else
{
await WriteHtml(res, PageError("Missing authorization code."));
tcs.TrySetResult(null);
}
}
else
{
res.StatusCode = 404;
res.Close();
}
}
catch (Exception ex2)
{
Plugin.Logger.LogWarning((object)("WKMusic: BrowserSetup handler error: " + ex2.Message));
try
{
res.Abort();
}
catch
{
}
}
}
private static string? ValidateCredentials(string id, string secret)
{
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(secret))
{
return "Both fields are required.";
}
return null;
}
private static async Task<string?> VerifyWithSpotifyAsync(string clientId, string clientSecret)
{
_ = 1;
try
{
FormUrlEncodedContent content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = clientId,
["client_secret"] = clientSecret
});
HttpResponseMessage httpResponseMessage = await _http.PostAsync("https://accounts.spotify.com/api/token", content);
if (httpResponseMessage.IsSuccessStatusCode)
{
return null;
}
JToken obj = JObject.Parse(await httpResponseMessage.Content.ReadAsStringAsync())["error"];
string text = ((obj != null) ? Extensions.Value<string>((IEnumerable<JToken>)obj) : null) ?? "";
return (text == "invalid_client") ? "Invalid Client ID or Client Secret — check your Spotify Dashboard." : ("Spotify returned: " + text);
}
catch
{
return null;
}
}
private static string BuildAuthUrl(string clientId, string state, string redirectUri)
{
string text = string.Join("&", new Dictionary<string, string>
{
["client_id"] = clientId,
["response_type"] = "code",
["redirect_uri"] = redirectUri,
["scope"] = string.Join(" ", Scopes),
["state"] = state
}.Select((KeyValuePair<string, string> kv) => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value)));
return "https://accounts.spotify.com/authorize?" + text;
}
private static async Task WriteHtml(HttpListenerResponse res, string html)
{
byte[] bytes = Encoding.UTF8.GetBytes(html);
res.ContentType = "text/html; charset=utf-8";
res.ContentLength64 = bytes.Length;
await res.OutputStream.WriteAsync(bytes, 0, bytes.Length);
res.Close();
}
private static void Redirect(HttpListenerResponse res, string url)
{
res.StatusCode = 302;
res.Headers["Location"] = url;
res.Close();
}
private static async Task<Dictionary<string, string>> ReadForm(HttpListenerRequest req)
{
using StreamReader reader = new StreamReader(req.InputStream, Encoding.UTF8);
string obj = await reader.ReadToEndAsync();
Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string[] array = obj.Split('&');
foreach (string text in array)
{
int num = text.IndexOf('=');
if (num >= 0)
{
string key = Uri.UnescapeDataString(text.Substring(0, num).Replace('+', ' '));
string text2 = text;
int num2 = num + 1;
dictionary[key] = Uri.UnescapeDataString(text2.Substring(num2, text2.Length - num2).Replace('+', ' '));
}
}
return dictionary;
}
private static int FindFreePort()
{
TcpListener tcpListener = new TcpListener(IPAddress.Loopback, 0);
tcpListener.Start();
int port = ((IPEndPoint)tcpListener.LocalEndpoint).Port;
tcpListener.Stop();
return port;
}
private static void OpenBrowser(string url)
{
try
{
Process.Start(new ProcessStartInfo(url)
{
UseShellExecute = true
});
}
catch
{
}
}
private static string? NullIfEmpty(string s)
{
if (!string.IsNullOrWhiteSpace(s))
{
return s.Trim();
}
return null;
}
private static string PageCredentials(string initialId = "", string error = "")
{
return "<!DOCTYPE html>\n<html lang='en'><head><meta charset='utf-8'><title>WKMusic — Spotify Setup</title>\n<style>\n body{font-family:'Segoe UI',sans-serif;background:#121212;color:#fff;display:flex;\n align-items:center;justify-content:center;min-height:100vh;margin:0}\n .card{background:#1e1e1e;border-radius:12px;padding:36px 40px;width:400px;\n box-shadow:0 8px 32px #0008}\n h1{margin:0 0 6px;font-size:1.4rem}\n .sub{color:#888;font-size:.9rem;margin:0 0 24px}\n .err{color:#e74c3c;font-size:.85rem;margin:0 0 14px}\n label{display:block;font-size:.85rem;color:#aaa;margin-bottom:4px}\n input{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:6px;\n border:1px solid #333;background:#2a2a2a;color:#fff;font-size:.95rem;margin-bottom:16px}\n input:focus{outline:none;border-color:#1db954}\n button{width:100%;padding:11px;background:#1db954;color:#000;font-weight:700;\n border:none;border-radius:6px;cursor:pointer;font-size:1rem}\n button:hover{background:#1ed760}\n .hint{font-size:.8rem;color:#666;margin-top:18px;line-height:1.6}\n a{color:#1db954}\n code{background:#2a2a2a;padding:1px 5px;border-radius:3px;font-size:.78rem}\n</style></head>\n<body><div class='card'>\n <h1>\ud83c\udfb5 WKMusic — Spotify Setup</h1>\n <p class='sub'>One-time setup. Token is saved locally.</p>\n " + (string.IsNullOrEmpty(error) ? "" : ("<p class='err'>" + error + "</p>")) + "\n <form method='POST' action='/submit'>\n <label>Client ID</label>\n <input name='clientId' value='" + initialId + "' placeholder='32-character hex string' autofocus required>\n <label>Client Secret</label>\n <input name='clientSecret' type='password' placeholder='32-character hex string' required>\n <button type='submit'>Connect Spotify →</button>\n </form>\n <p class='hint'>\n Get your credentials at <a href='https://developer.spotify.com/dashboard' target='_blank'>developer.spotify.com/dashboard</a>.<br>\n Add <code>https://wkmusic.eventeventeventevent1.workers.dev/callback</code> as a Redirect URI.\n </p>\n</div></body></html>";
}
private static string PageSuccess()
{
return "<!DOCTYPE html>\n<html lang='en'><head><meta charset='utf-8'><title>WKMusic — Connected</title>\n<style>\n body{font-family:'Segoe UI',sans-serif;background:#121212;color:#fff;display:flex;\n align-items:center;justify-content:center;min-height:100vh;margin:0}\n .card{background:#1e1e1e;border-radius:12px;padding:36px 40px;width:400px;\n box-shadow:0 8px 32px #0008}\n h1{margin:0 0 6px;font-size:1.4rem}\n .sub{color:#888;font-size:.9rem;margin:0 0 24px}\n .err{color:#e74c3c;font-size:.85rem;margin:0 0 14px}\n label{display:block;font-size:.85rem;color:#aaa;margin-bottom:4px}\n input{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:6px;\n border:1px solid #333;background:#2a2a2a;color:#fff;font-size:.95rem;margin-bottom:16px}\n input:focus{outline:none;border-color:#1db954}\n button{width:100%;padding:11px;background:#1db954;color:#000;font-weight:700;\n border:none;border-radius:6px;cursor:pointer;font-size:1rem}\n button:hover{background:#1ed760}\n .hint{font-size:.8rem;color:#666;margin-top:18px;line-height:1.6}\n a{color:#1db954}\n code{background:#2a2a2a;padding:1px 5px;border-radius:3px;font-size:.78rem}\n</style>\n<style>.card{text-align:center} .icon{font-size:3rem;margin-bottom:12px} h1{color:#1db954}</style>\n</head><body><div class='card'>\n <div class='icon'>\ud83c\udfb5</div>\n <h1>Connected!</h1>\n <p class='sub' style='margin:0'>Spotify is linked. You can close this tab and return to the game.</p>\n</div></body></html>";
}
private static string PageError(string message)
{
return "<!DOCTYPE html>\n<html lang='en'><head><meta charset='utf-8'><title>WKMusic — Error</title>\n<style>\n body{font-family:'Segoe UI',sans-serif;background:#121212;color:#fff;display:flex;\n align-items:center;justify-content:center;min-height:100vh;margin:0}\n .card{background:#1e1e1e;border-radius:12px;padding:36px 40px;width:400px;\n box-shadow:0 8px 32px #0008}\n h1{margin:0 0 6px;font-size:1.4rem}\n .sub{color:#888;font-size:.9rem;margin:0 0 24px}\n .err{color:#e74c3c;font-size:.85rem;margin:0 0 14px}\n label{display:block;font-size:.85rem;color:#aaa;margin-bottom:4px}\n input{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:6px;\n border:1px solid #333;background:#2a2a2a;color:#fff;font-size:.95rem;margin-bottom:16px}\n input:focus{outline:none;border-color:#1db954}\n button{width:100%;padding:11px;background:#1db954;color:#000;font-weight:700;\n border:none;border-radius:6px;cursor:pointer;font-size:1rem}\n button:hover{background:#1ed760}\n .hint{font-size:.8rem;color:#666;margin-top:18px;line-height:1.6}\n a{color:#1db954}\n code{background:#2a2a2a;padding:1px 5px;border-radius:3px;font-size:.78rem}\n</style>\n<style>.card{text-align:center} h1{color:#e74c3c}</style>\n</head><body><div class='card'>\n <h1>⚠ Error</h1>\n <p class='sub' style='margin:0'>" + message + "</p>\n</div></body></html>";
}
}
public class CredentialsStorage
{
public sealed record SpotifyCredentials(string ClientId, string ClientSecret);
private static class NativeMethods
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CREDENTIAL
{
public uint Flags;
public uint Type;
public string TargetName;
public string? Comment;
public long LastWritten;
public uint CredentialBlobSize;
public IntPtr CredentialBlob;
public uint Persist;
public uint AttributeCount;
public IntPtr Attributes;
public string? TargetAlias;
public string? UserName;
}
public const uint CRED_TYPE_GENERIC = 1u;
public const uint CRED_PERSIST_LOCAL_MACHINE = 2u;
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool CredWrite(ref CREDENTIAL credential, uint flags);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential);
[DllImport("advapi32.dll")]
public static extern void CredFree(IntPtr buffer);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode)]
public static extern bool CredDelete(string target, uint type, uint flags);
}
private const string CredentialName = "WKMusic/SpotifyCredentials";
public void Save(string clientId, string clientSecret)
{
string s = JsonConvert.SerializeObject((object)new
{
ClientId = clientId,
ClientSecret = clientSecret
});
byte[] bytes = Encoding.UTF8.GetBytes(s);
NativeMethods.CREDENTIAL cREDENTIAL = default(NativeMethods.CREDENTIAL);
cREDENTIAL.Type = 1u;
cREDENTIAL.TargetName = "WKMusic/SpotifyCredentials";
cREDENTIAL.CredentialBlob = Marshal.AllocHGlobal(bytes.Length);
cREDENTIAL.CredentialBlobSize = (uint)bytes.Length;
cREDENTIAL.Persist = 2u;
cREDENTIAL.UserName = "wkmusic";
NativeMethods.CREDENTIAL credential = cREDENTIAL;
Marshal.Copy(bytes, 0, credential.CredentialBlob, bytes.Length);
try
{
if (!NativeMethods.CredWrite(ref credential, 0u))
{
throw new Exception($"CredWrite failed: {Marshal.GetLastWin32Error()}");
}
}
finally
{
Marshal.FreeHGlobal(credential.CredentialBlob);
}
}
public SpotifyCredentials? Load()
{
if (!NativeMethods.CredRead("WKMusic/SpotifyCredentials", 1u, 0u, out var credential))
{
return null;
}
try
{
NativeMethods.CREDENTIAL cREDENTIAL = Marshal.PtrToStructure<NativeMethods.CREDENTIAL>(credential);
byte[] array = new byte[cREDENTIAL.CredentialBlobSize];
Marshal.Copy(cREDENTIAL.CredentialBlob, array, 0, array.Length);
var anon = JsonConvert.DeserializeAnonymousType(Encoding.UTF8.GetString(array), new
{
ClientId = "",
ClientSecret = ""
});
if (anon == null || string.IsNullOrWhiteSpace(anon.ClientId))
{
return null;
}
return new SpotifyCredentials(anon.ClientId, anon.ClientSecret);
}
catch
{
Clear();
return null;
}
finally
{
NativeMethods.CredFree(credential);
}
}
public void Clear()
{
NativeMethods.CredDelete("WKMusic/SpotifyCredentials", 1u, 0u);
}
}
public interface IMusicClient
{
Task InitializeAsync(CancellationToken ct = default(CancellationToken));
Task<PlaybackState> GetPlaybackStateAsync(CancellationToken ct = default(CancellationToken));
}
internal static class MyPluginInfo
{
public const string PLUGIN_GUID = "dev.wkmusic";
public const string PLUGIN_NAME = "WKMusic";
public const string PLUGIN_VERSION = "1.0.0";
}
[BepInPlugin("dev.wkmusic", "WKMusic", "1.0.0")]
public class Plugin : BaseUnityPlugin
{
internal static ManualLogSource Logger = null;
internal static Plugin Instance = null;
internal volatile PlaybackState State = PlaybackState.Empty;
internal volatile bool Initialized;
internal volatile bool Initializing;
internal DateTime StateUpdatedAtUtc = DateTime.UtcNow;
internal volatile byte[]? PendingCoverBytes;
internal static float PlayerScale = 0.8f;
internal static float CoverSize = 64f;
internal static float CoverOpacity = 0.6f;
private void Awake()
{
//IL_0075: Unknown result type (might be due to invalid IL or missing references)
//IL_007f: Expected O, but got Unknown
//IL_00b7: Unknown result type (might be due to invalid IL or missing references)
//IL_00c1: Expected O, but got Unknown
//IL_00f9: Unknown result type (might be due to invalid IL or missing references)
//IL_0103: Expected O, but got Unknown
//IL_0160: Unknown result type (might be due to invalid IL or missing references)
Instance = this;
Logger = ((BaseUnityPlugin)this).Logger;
string workerUrl = ((BaseUnityPlugin)this).Config.Bind<string>("Spotify", "WorkerUrl", "https://wkmusic.eventeventeventevent1.workers.dev", "Cloudflare Worker URL; change only if you host your own").Value;
PlayerScale = ((BaseUnityPlugin)this).Config.Bind<float>("HUD", "PlayerScale", 0.8f, new ConfigDescription("Overall player scale (affects font size and layout dimensions)", (AcceptableValueBase)(object)new AcceptableValueRange<float>(0.5f, 3f), Array.Empty<object>())).Value;
CoverSize = ((BaseUnityPlugin)this).Config.Bind<float>("HUD", "CoverSize", 64f, new ConfigDescription("Album cover size in pixels", (AcceptableValueBase)(object)new AcceptableValueRange<float>(20f, 300f), Array.Empty<object>())).Value;
CoverOpacity = ((BaseUnityPlugin)this).Config.Bind<float>("HUD", "CoverOpacity", 0.6f, new ConfigDescription("Album cover opacity (0 = invisible, 1 = fully opaque)", (AcceptableValueBase)(object)new AcceptableValueRange<float>(0f, 1f), Array.Empty<object>())).Value;
CredentialsStorage credStorage = new CredentialsStorage();
CredentialsStorage.SpotifyCredentials saved = credStorage.Load();
Task.Run(async delegate
{
Initializing = true;
try
{
TokenStorage tokenStorage = new TokenStorage();
string clientId;
string clientSecret;
if ((object)saved == null)
{
Logger.LogInfo((object)"WKMusic: credentials missing, opening browser setup...");
BrowserSetup.SetupResult setupResult = await BrowserSetup.RunAsync(workerUrl, delegate(string id, string secret, string code)
{
SpotifyAuth spotifyAuth2 = new SpotifyAuth(workerUrl, secret);
string redirectUri2 = workerUrl + "/callback";
return spotifyAuth2.ExchangeCodeAsync(id, code, redirectUri2, default(CancellationToken));
});
if ((object)setupResult == null)
{
Logger.LogWarning((object)"WKMusic: setup cancelled.");
return;
}
credStorage.Save(setupResult.ClientId, setupResult.ClientSecret);
tokenStorage.Save(setupResult.Token);
clientId = setupResult.ClientId;
clientSecret = setupResult.ClientSecret;
}
else
{
clientId = saved.ClientId;
clientSecret = saved.ClientSecret;
}
SpotifyClient client = new SpotifyClient(new SpotifyAuth(workerUrl, clientSecret), tokenStorage, clientId);
try
{
await client.InitializeAsync();
}
catch (SpotifyAuthException ex) when ((object)saved != null && ex.Error == "invalid_client")
{
Logger.LogWarning((object)"WKMusic: saved Spotify credentials were rejected; clearing them and reopening setup.");
tokenStorage.Clear();
credStorage.Clear();
BrowserSetup.SetupResult setupResult2 = await BrowserSetup.RunAsync(workerUrl, delegate(string id, string secret, string code)
{
SpotifyAuth spotifyAuth = new SpotifyAuth(workerUrl, secret);
string redirectUri = workerUrl + "/callback";
return spotifyAuth.ExchangeCodeAsync(id, code, redirectUri, default(CancellationToken));
}, clientId);
if ((object)setupResult2 == null)
{
Logger.LogWarning((object)"WKMusic: setup cancelled.");
return;
}
credStorage.Save(setupResult2.ClientId, setupResult2.ClientSecret);
tokenStorage.Save(setupResult2.Token);
client = new SpotifyClient(new SpotifyAuth(workerUrl, setupResult2.ClientSecret), tokenStorage, setupResult2.ClientId);
await client.InitializeAsync();
}
Initialized = true;
Logger.LogInfo((object)"WKMusic: Spotify connected.");
StartPolling(client);
}
catch (Exception ex2)
{
Logger.LogError((object)("WKMusic: init failed — " + ex2.GetType().Name + ": " + ex2.Message));
Logger.LogError((object)(ex2.StackTrace ?? ""));
}
finally
{
Initializing = false;
}
});
DOTween.Init((bool?)null, (bool?)null, (LogBehaviour?)null);
new Harmony("dev.wkmusic").PatchAll();
}
private void StartPolling(IMusicClient client)
{
IMusicClient client2 = client;
HttpClient http = new HttpClient();
string lastCoverToken = null;
Task.Run(async delegate
{
while (true)
{
int delayMs;
try
{
PlaybackState state = await client2.GetPlaybackStateAsync();
State = state;
StateUpdatedAtUtc = DateTime.UtcNow;
delayMs = 1000;
TrackInfo track = State.Track;
string text = (((object)track == null) ? null : ((track.CoverBytes != null) ? ("embedded:" + track.Id) : track.CoverUrl));
if (text != lastCoverToken)
{
lastCoverToken = text;
byte[] pendingCoverBytes;
if (track?.CoverBytes != null)
{
pendingCoverBytes = track.CoverBytes;
}
else
{
byte[] array = (((object)track == null || track.CoverUrl == null) ? Array.Empty<byte>() : (await http.GetByteArrayAsync(track.CoverUrl)));
pendingCoverBytes = array;
}
PendingCoverBytes = pendingCoverBytes;
}
}
catch (SpotifyRateLimitException ex)
{
int num = Math.Max(1000, (int)Math.Ceiling(ex.RetryAfter.TotalMilliseconds));
delayMs = ((num <= 30000) ? num : num);
string statusMessage = ((num > 30000) ? ("Spotify rate limited (" + FormatDelay(delayMs) + ")") : "Spotify rate limited");
State = State with
{
StatusMessage = statusMessage
};
StateUpdatedAtUtc = DateTime.UtcNow;
Logger.LogWarning((object)($"WKMusic: Spotify rate limited polling; retrying in {delayMs} ms. " + string.Format("Retry-After={0}, requested={1} ms.", ex.RawRetryAfter ?? "missing", num)));
}
catch (Exception ex2)
{
delayMs = 5000;
Logger.LogWarning((object)("WKMusic: poll error - " + ex2.GetType().Name + ": " + ex2.Message + "\n" + ex2.StackTrace));
}
await Task.Delay(delayMs);
}
});
}
internal static string Truncate(string s, int max)
{
if (s.Length > max)
{
return s.Substring(0, max - 3) + "...";
}
return s;
}
private static string FormatDelay(int delayMs)
{
TimeSpan timeSpan = TimeSpan.FromMilliseconds(delayMs);
if (timeSpan.TotalHours >= 1.0)
{
return $"{(int)timeSpan.TotalHours}h {timeSpan.Minutes}m";
}
if (timeSpan.TotalMinutes >= 1.0)
{
return $"{(int)timeSpan.TotalMinutes}m {timeSpan.Seconds}s";
}
return $"{timeSpan.Seconds}s";
}
}
[HarmonyPatch(typeof(CL_UIManager), "Awake")]
internal static class Patch_UIManager_Awake
{
private const float RightMargin = 10f;
private const float TextWidth = 320f;
private const float CoverGap = 10f;
private static void Postfix(CL_UIManager __instance)
{
//IL_006c: Unknown result type (might be due to invalid IL or missing references)
//IL_0071: Unknown result type (might be due to invalid IL or missing references)
//IL_007e: Unknown result type (might be due to invalid IL or missing references)
//IL_0097: Unknown result type (might be due to invalid IL or missing references)
//IL_009e: Unknown result type (might be due to invalid IL or missing references)
//IL_00b0: Unknown result type (might be due to invalid IL or missing references)
//IL_00bb: Unknown result type (might be due to invalid IL or missing references)
//IL_00c5: Unknown result type (might be due to invalid IL or missing references)
//IL_00cf: Unknown result type (might be due to invalid IL or missing references)
//IL_0111: Unknown result type (might be due to invalid IL or missing references)
//IL_013f: Unknown result type (might be due to invalid IL or missing references)
Plugin.Logger.LogInfo((object)"WKMusic: CL_UIManager.Awake fired.");
Transform canvas = __instance.canvas;
TMP_Text timer = __instance.timer;
if ((Object)(object)canvas == (Object)null || (Object)(object)timer == (Object)null)
{
Plugin.Logger.LogWarning((object)$"WKMusic: canvas={canvas}, timer={timer} - aborting.");
return;
}
float playerScale = Plugin.PlayerScale;
float coverSize = Plugin.CoverSize;
float coverRight = 10f + 320f * playerScale + 10f;
GameObject val = new GameObject("WKMusic_HUD");
val.transform.SetParent(canvas, false);
RectTransform obj = val.AddComponent<RectTransform>();
Vector2 val2 = default(Vector2);
((Vector2)(ref val2))..ctor(1f, 1f);
obj.anchorMax = val2;
obj.anchorMin = val2;
obj.pivot = new Vector2(1f, 1f);
obj.anchoredPosition = Vector2.zero;
obj.sizeDelta = Vector2.zero;
Patch_UIManager_Update.HudGroup = val.AddComponent<CanvasGroup>();
Transform transform = val.transform;
Patch_UIManager_Update.CoverA = CreateCover(transform, coverSize, coverRight);
Patch_UIManager_Update.CoverB = CreateCover(transform, coverSize, coverRight);
Patch_UIManager_Update.RestoreCover();
Patch_UIManager_Update.TrackLabel = CreateLabel(transform, timer, "WKMusic_Track", new Vector2(-10f, -10f), playerScale, (TextAlignmentOptions)258, 42f * playerScale);
Patch_UIManager_Update.ProgressLabel = CreateLabel(transform, timer, "WKMusic_Progress", new Vector2(-10f, -62f * playerScale), playerScale, (TextAlignmentOptions)257);
Patch_UIManager_Update.ResetCache();
if (!Plugin.Instance.Initialized)
{
Patch_UIManager_Update.TrackLabel.text = "WKMusic: loading...";
}
Plugin.Logger.LogInfo((object)"WKMusic: HUD created.");
}
private static RawImage CreateCover(Transform canvas, float size, float coverRight)
{
//IL_0005: Unknown result type (might be due to invalid IL or missing references)
//IL_000a: Unknown result type (might be due to invalid IL or missing references)
//IL_0017: Unknown result type (might be due to invalid IL or missing references)
//IL_0034: Unknown result type (might be due to invalid IL or missing references)
//IL_0049: Unknown result type (might be due to invalid IL or missing references)
//IL_005e: Unknown result type (might be due to invalid IL or missing references)
//IL_0070: Unknown result type (might be due to invalid IL or missing references)
//IL_007c: Unknown result type (might be due to invalid IL or missing references)
GameObject val = new GameObject("WKMusic_Cover");
val.transform.SetParent(canvas, false);
val.SetActive(false);
RawImage obj = val.AddComponent<RawImage>();
RectTransform rectTransform = ((Graphic)obj).rectTransform;
rectTransform.anchorMin = new Vector2(1f, 1f);
rectTransform.anchorMax = new Vector2(1f, 1f);
rectTransform.pivot = new Vector2(1f, 1f);
rectTransform.anchoredPosition = new Vector2(0f - coverRight, -10f);
rectTransform.sizeDelta = new Vector2(size, size);
return obj;
}
private static TMP_Text CreateLabel(Transform canvas, TMP_Text reference, string name, Vector2 offset, float scale = 1f, TextAlignmentOptions alignment = 257, float height = 30f)
{
//IL_0001: Unknown result type (might be due to invalid IL or missing references)
//IL_0006: Unknown result type (might be due to invalid IL or missing references)
//IL_003b: Unknown result type (might be due to invalid IL or missing references)
//IL_0046: Unknown result type (might be due to invalid IL or missing references)
//IL_0073: Unknown result type (might be due to invalid IL or missing references)
//IL_0088: Unknown result type (might be due to invalid IL or missing references)
//IL_009d: Unknown result type (might be due to invalid IL or missing references)
//IL_00a8: Unknown result type (might be due to invalid IL or missing references)
//IL_00b8: Unknown result type (might be due to invalid IL or missing references)
GameObject val = new GameObject(name);
val.transform.SetParent(canvas, false);
TextMeshProUGUI obj = val.AddComponent<TextMeshProUGUI>();
((TMP_Text)obj).font = reference.font;
((TMP_Text)obj).fontSize = reference.fontSize * 0.85f * scale;
((Graphic)obj).color = ((Graphic)reference).color;
((TMP_Text)obj).alignment = alignment;
((TMP_Text)obj).enableWordWrapping = false;
((TMP_Text)obj).overflowMode = (TextOverflowModes)0;
((TMP_Text)obj).richText = true;
RectTransform rectTransform = ((TMP_Text)obj).rectTransform;
rectTransform.anchorMin = new Vector2(1f, 1f);
rectTransform.anchorMax = new Vector2(1f, 1f);
rectTransform.pivot = new Vector2(1f, 1f);
rectTransform.anchoredPosition = offset;
rectTransform.sizeDelta = new Vector2(320f * scale, height);
return (TMP_Text)(object)obj;
}
}
[HarmonyPatch(typeof(CL_UIManager), "Update")]
internal static class Patch_UIManager_Update
{
internal static TMP_Text? TrackLabel;
internal static TMP_Text? ProgressLabel;
internal static CanvasGroup? HudGroup;
internal static RawImage? CoverA;
internal static RawImage? CoverB;
private static bool _aIsFront = true;
private static Texture2D? _coverTexA;
private static Texture2D? _coverTexB;
private static byte[]? _lastCoverBytes;
private static bool _wasPaused;
private static string? _lastTrackText;
private static float _trackAlpha = 1f;
private static RawImage? Front
{
get
{
if (!_aIsFront)
{
return CoverB;
}
return CoverA;
}
}
private static RawImage? Back
{
get
{
if (!_aIsFront)
{
return CoverA;
}
return CoverB;
}
}
internal static void ResetCache()
{
if ((Object)(object)TrackLabel != (Object)null)
{
TrackLabel.text = "";
}
if ((Object)(object)ProgressLabel != (Object)null)
{
ProgressLabel.text = "";
}
_wasPaused = false;
_lastTrackText = null;
_trackAlpha = 1f;
DOTween.Kill((object)"coverA", false);
DOTween.Kill((object)"coverB", false);
_aIsFront = true;
}
internal static void RestoreCover()
{
//IL_004b: Unknown result type (might be due to invalid IL or missing references)
if (_lastCoverBytes == null)
{
return;
}
RawImage front = Front;
if (!((Object)(object)front == (Object)null))
{
SetLayerTexture(front, ref _aIsFront ? ref _coverTexA : ref _coverTexB, _lastCoverBytes);
((Graphic)front).color = new Color(1f, 1f, 1f, Plugin.CoverOpacity);
((Component)front).gameObject.SetActive(true);
RawImage back = Back;
if ((Object)(object)back != (Object)null)
{
((Component)back).gameObject.SetActive(false);
}
}
}
private static void SetLayerTexture(RawImage layer, ref Texture2D? texSlot, byte[] bytes)
{
//IL_0017: Unknown result type (might be due to invalid IL or missing references)
//IL_001d: Expected O, but got Unknown
if ((Object)(object)texSlot != (Object)null)
{
Object.Destroy((Object)(object)texSlot);
}
texSlot = new Texture2D(2, 2, (TextureFormat)4, false, false);
if (ImageConversion.LoadImage(texSlot, bytes))
{
((Texture)texSlot).wrapMode = (TextureWrapMode)1;
texSlot.Apply();
}
layer.texture = (Texture)(object)texSlot;
}
internal static void ApplyCover(byte[] bytes)
{
//IL_007d: Unknown result type (might be due to invalid IL or missing references)
//IL_0104: Unknown result type (might be due to invalid IL or missing references)
//IL_013a: Unknown result type (might be due to invalid IL or missing references)
//IL_0144: Expected O, but got Unknown
RawImage back = Back;
if ((Object)(object)back == (Object)null)
{
return;
}
DOTween.Kill((object)"coverA", false);
DOTween.Kill((object)"coverB", false);
if (_aIsFront)
{
SetLayerTexture(back, ref _coverTexB, bytes);
}
else
{
SetLayerTexture(back, ref _coverTexA, bytes);
}
((Graphic)back).color = new Color(1f, 1f, 1f, 0f);
((Component)back).gameObject.SetActive(true);
((Component)back).transform.SetAsLastSibling();
RawImage front = Front;
TweenSettingsExtensions.SetId<Tweener>(DOVirtual.Float(0f, Plugin.CoverOpacity, 0.6f, (TweenCallback<float>)delegate(float v)
{
//IL_0024: Unknown result type (might be due to invalid IL or missing references)
if ((Object)(object)back != (Object)null)
{
((Graphic)back).color = new Color(1f, 1f, 1f, v);
}
}), "coverB");
if ((Object)(object)front != (Object)null && ((Component)front).gameObject.activeSelf)
{
TweenSettingsExtensions.OnComplete<Tweener>(TweenSettingsExtensions.SetId<Tweener>(DOVirtual.Float(((Graphic)front).color.a, 0f, 0.6f, (TweenCallback<float>)delegate(float v)
{
//IL_0024: Unknown result type (might be due to invalid IL or missing references)
if ((Object)(object)front != (Object)null)
{
((Graphic)front).color = new Color(1f, 1f, 1f, v);
}
}), "coverA"), (TweenCallback)delegate
{
if ((Object)(object)front != (Object)null)
{
((Component)front).gameObject.SetActive(false);
}
});
}
_aIsFront = !_aIsFront;
}
internal static void HideCover()
{
//IL_0050: Unknown result type (might be due to invalid IL or missing references)
//IL_0086: Unknown result type (might be due to invalid IL or missing references)
//IL_0090: Expected O, but got Unknown
DOTween.Kill((object)"coverA", false);
DOTween.Kill((object)"coverB", false);
RawImage front = Front;
if ((Object)(object)front == (Object)null || !((Component)front).gameObject.activeSelf)
{
return;
}
TweenSettingsExtensions.OnComplete<Tweener>(TweenSettingsExtensions.SetId<Tweener>(DOVirtual.Float(((Graphic)front).color.a, 0f, 0.5f, (TweenCallback<float>)delegate(float v)
{
//IL_0024: Unknown result type (might be due to invalid IL or missing references)
if ((Object)(object)front != (Object)null)
{
((Graphic)front).color = new Color(1f, 1f, 1f, v);
}
}), "coverA"), (TweenCallback)delegate
{
if ((Object)(object)front != (Object)null)
{
((Component)front).gameObject.SetActive(false);
}
});
}
private static void SetTrackLabel(string newText)
{
//IL_0067: Unknown result type (might be due to invalid IL or missing references)
//IL_006c: Unknown result type (might be due to invalid IL or missing references)
//IL_0072: Unknown result type (might be due to invalid IL or missing references)
//IL_0078: Unknown result type (might be due to invalid IL or missing references)
//IL_007e: Unknown result type (might be due to invalid IL or missing references)
//IL_0089: Unknown result type (might be due to invalid IL or missing references)
if (!((Object)(object)TrackLabel == (Object)null))
{
if (_lastTrackText != newText)
{
_lastTrackText = newText;
TrackLabel.text = newText;
_trackAlpha = 0f;
}
if (_trackAlpha < 1f)
{
_trackAlpha = Mathf.Min(1f, _trackAlpha + Time.deltaTime * 2.5f);
Color color = ((Graphic)TrackLabel).color;
((Graphic)TrackLabel).color = new Color(color.r, color.g, color.b, _trackAlpha);
}
}
}
private static void Postfix()
{
if ((Object)(object)TrackLabel == (Object)null || (Object)(object)ProgressLabel == (Object)null)
{
return;
}
Plugin instance = Plugin.Instance;
byte[] pendingCoverBytes = instance.PendingCoverBytes;
if (pendingCoverBytes != null)
{
instance.PendingCoverBytes = null;
if (pendingCoverBytes.Length == 0)
{
_lastCoverBytes = null;
HideCover();
}
else
{
_lastCoverBytes = pendingCoverBytes;
ApplyCover(pendingCoverBytes);
}
}
if (instance.Initializing)
{
SetTrackLabel("WKMusic: authorizing...");
ProgressLabel.text = "";
return;
}
if (!instance.Initialized)
{
SetTrackLabel("");
ProgressLabel.text = "";
return;
}
PlaybackState state = instance.State;
if (!string.IsNullOrEmpty(state.StatusMessage))
{
SetTrackLabel(state.StatusMessage);
ProgressLabel.text = "";
return;
}
if ((Object)(object)HudGroup != (Object)null)
{
bool flag = state.Track != null && !state.IsPlaying;
if (flag != _wasPaused)
{
_wasPaused = flag;
DOVirtual.Float(HudGroup.alpha, flag ? 0.3f : 1f, 0.5f, (TweenCallback<float>)delegate(float v)
{
HudGroup.alpha = v;
});
TweenSettingsExtensions.SetEase<TweenerCore<Vector3, Vector3, VectorOptions>>(ShortcutExtensions.DOScale(((Component)HudGroup).transform, flag ? 0.75f : 1f, 0.5f), (Ease)9);
}
}
if ((object)state.Track == null)
{
SetTrackLabel("Nothing playing");
ProgressLabel.text = "";
return;
}
TrackInfo track = state.Track;
int value = (state.IsPlaying ? (state.ProgressMs + (int)(DateTime.UtcNow - instance.StateUpdatedAtUtc).TotalMilliseconds) : state.ProgressMs);
value = Math.Clamp(value, 0, track.DurationMs);
TimeSpan timeSpan = TimeSpan.FromMilliseconds(value);
TimeSpan timeSpan2 = TimeSpan.FromMilliseconds(track.DurationMs);
int num = Math.Clamp((int)Math.Round(((track.DurationMs > 0) ? ((double)value / (double)track.DurationMs) : 0.0) * 50.0), 0, 50);
string arg = new string('-', num) + "|<color=#303030>" + new string('-', 50 - num) + "</color>";
SetTrackLabel(Plugin.Truncate(track.Title, 40) + "\n<line-height=65%><color=#888888><size=80%>" + Plugin.Truncate(track.Artist, 40) + "</size></color>");
ProgressLabel.text = $"<mspace=0.45em>{timeSpan:m\\:ss}</mspace> <mspace=0.22em>{arg}</mspace> <mspace=0.45em>{timeSpan2:m\\:ss}</mspace>";
}
}
public class SpotifyAuthException : Exception
{
public string? Error { get; }
public string ResponseBody { get; }
public SpotifyAuthException(string message, string? error, string responseBody)
{
Error = error;
ResponseBody = responseBody;
base..ctor(message);
}
}
public class SpotifyAuth
{
private const string TokenUrl = "https://accounts.spotify.com/api/token";
private readonly HttpClient _http = http ?? new HttpClient();
private readonly string _workerUrl = workerUrl.TrimEnd('/');
private readonly string _clientSecret;
public SpotifyAuth(string workerUrl, string clientSecret, HttpClient? http = null)
{
_clientSecret = clientSecret;
base..ctor();
}
public async Task<SpotifyToken> AuthorizeAsync(string clientId, CancellationToken ct = default(CancellationToken))
{
string redirectUri = _workerUrl + "/callback";
return ((await BrowserSetup.RunAsync(_workerUrl, (string id, string _, string code) => ExchangeCodeAsync(id, code, redirectUri, ct), clientId, "", ct)) ?? throw new OperationCanceledException("Spotify authorization cancelled.")).Token;
}
public async Task<SpotifyToken> RefreshAsync(string clientId, SpotifyToken token, CancellationToken ct = default(CancellationToken))
{
Dictionary<string, string> nameValueCollection = new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = token.RefreshToken,
["client_id"] = clientId,
["client_secret"] = _clientSecret
};
HttpResponseMessage response = await _http.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(nameValueCollection), ct);
string text = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
string error = TryReadSpotifyError(text);
throw new SpotifyAuthException("Token refresh failed: " + text, error, text);
}
JObject val = JObject.Parse(text);
string accessToken = Extensions.Value<string>((IEnumerable<JToken>)val["access_token"]);
JToken obj = val["refresh_token"];
return new SpotifyToken(accessToken, ((obj != null) ? Extensions.Value<string>((IEnumerable<JToken>)obj) : null) ?? token.RefreshToken, Extensions.Value<int>((IEnumerable<JToken>)val["expires_in"]), DateTime.UtcNow);
}
internal async Task<SpotifyToken> ExchangeCodeAsync(string clientId, string code, string redirectUri, CancellationToken ct)
{
Dictionary<string, string> nameValueCollection = new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = redirectUri,
["client_id"] = clientId,
["client_secret"] = _clientSecret
};
HttpResponseMessage response = await _http.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(nameValueCollection), ct);
string text = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
string error = TryReadSpotifyError(text);
throw new SpotifyAuthException("Token exchange failed: " + text, error, text);
}
JObject val = JObject.Parse(text);
string accessToken = Extensions.Value<string>((IEnumerable<JToken>)val["access_token"]);
JToken obj = val["refresh_token"];
return new SpotifyToken(accessToken, ((obj != null) ? Extensions.Value<string>((IEnumerable<JToken>)obj) : null) ?? string.Empty, Extensions.Value<int>((IEnumerable<JToken>)val["expires_in"]), DateTime.UtcNow);
}
private static string? TryReadSpotifyError(string json)
{
try
{
JToken obj = JObject.Parse(json)["error"];
return (obj != null) ? Extensions.Value<string>((IEnumerable<JToken>)obj) : null;
}
catch
{
return null;
}
}
}
public class SpotifyRateLimitException : Exception
{
public TimeSpan RetryAfter { get; }
public string? RawRetryAfter { get; }
public SpotifyRateLimitException(TimeSpan retryAfter, string? rawRetryAfter)
{
RetryAfter = retryAfter;
RawRetryAfter = rawRetryAfter;
base..ctor($"Spotify rate limit reached. Retry after {retryAfter.TotalSeconds:0.#}s.");
}
}
public class SpotifyClient : IMusicClient
{
[CompilerGenerated]
private SpotifyAuth <auth>P;
[CompilerGenerated]
private TokenStorage <storage>P;
private const string ApiBase = "https://api.spotify.com/v1";
private readonly HttpClient _http;
private readonly string _clientId;
private SpotifyToken? _token;
public SpotifyClient(SpotifyAuth auth, TokenStorage storage, string clientId, HttpClient? http = null)
{
<auth>P = auth;
<storage>P = storage;
_http = http ?? new HttpClient();
_clientId = clientId;
base..ctor();
}
public async Task InitializeAsync(CancellationToken ct = default(CancellationToken))
{
_token = <storage>P.Load();
if ((object)_token == null)
{
_token = await <auth>P.AuthorizeAsync(_clientId, ct);
<storage>P.Save(_token);
}
else if (_token.IsExpired)
{
await ForceRefreshAsync(ct);
}
}
public async Task<PlaybackState> GetPlaybackStateAsync(CancellationToken ct = default(CancellationToken))
{
string text = await GetAsync("/me/player", ct);
if (text == null)
{
return PlaybackState.Empty;
}
JObject val = JObject.Parse(text);
JToken val2 = val["item"];
if (val2 == null || (int)val2.Type == 10)
{
return PlaybackState.Empty;
}
TrackInfo track = ParseTrack(val2);
bool isPlaying = Extensions.Value<bool>((IEnumerable<JToken>)val["is_playing"]);
int progressMs = Extensions.Value<int>((IEnumerable<JToken>)val["progress_ms"]);
return new PlaybackState(track, isPlaying, progressMs);
}
public Task PlayAsync(CancellationToken ct = default(CancellationToken))
{
return PutAsync("/me/player/play", ct);
}
public Task PauseAsync(CancellationToken ct = default(CancellationToken))
{
return PutAsync("/me/player/pause", ct);
}
public Task NextAsync(CancellationToken ct = default(CancellationToken))
{
return PostAsync("/me/player/next", ct);
}
public Task PreviousAsync(CancellationToken ct = default(CancellationToken))
{
return PostAsync("/me/player/previous", ct);
}
public Task SeekAsync(int positionMs, CancellationToken ct = default(CancellationToken))
{
return PutAsync($"/me/player/seek?position_ms={positionMs}", ct);
}
public Task SetVolumeAsync(int volumePercent, CancellationToken ct = default(CancellationToken))
{
return PutAsync($"/me/player/volume?volume_percent={Math.Clamp(volumePercent, 0, 100)}", ct);
}
private async Task<string?> GetAsync(string path, CancellationToken ct)
{
await EnsureTokenValidAsync(ct);
HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1" + path);
httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token.AccessToken);
HttpResponseMessage httpResponseMessage = await _http.SendAsync(httpRequestMessage, ct);
if (httpResponseMessage.StatusCode == HttpStatusCode.NoContent)
{
return null;
}
if (httpResponseMessage.StatusCode == HttpStatusCode.Unauthorized)
{
await ForceRefreshAsync(ct);
return await GetAsync(path, ct);
}
EnsureNotRateLimited(httpResponseMessage);
httpResponseMessage.EnsureSuccessStatusCode();
return await httpResponseMessage.Content.ReadAsStringAsync();
}
private async Task PutAsync(string path, CancellationToken ct)
{
await EnsureTokenValidAsync(ct);
HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Put, "https://api.spotify.com/v1" + path);
httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token.AccessToken);
httpRequestMessage.Content = new StringContent("", Encoding.UTF8, "application/json");
HttpResponseMessage httpResponseMessage = await _http.SendAsync(httpRequestMessage, ct);
if (httpResponseMessage.StatusCode == HttpStatusCode.Unauthorized)
{
await ForceRefreshAsync(ct);
await PutAsync(path, ct);
}
else if (httpResponseMessage.StatusCode != HttpStatusCode.NoContent)
{
EnsureNotRateLimited(httpResponseMessage);
httpResponseMessage.EnsureSuccessStatusCode();
}
}
private async Task PostAsync(string path, CancellationToken ct)
{
await EnsureTokenValidAsync(ct);
HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "https://api.spotify.com/v1" + path);
httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token.AccessToken);
httpRequestMessage.Content = new StringContent("", Encoding.UTF8, "application/json");
HttpResponseMessage httpResponseMessage = await _http.SendAsync(httpRequestMessage, ct);
if (httpResponseMessage.StatusCode == HttpStatusCode.Unauthorized)
{
await ForceRefreshAsync(ct);
await PostAsync(path, ct);
}
else if (httpResponseMessage.StatusCode != HttpStatusCode.NoContent)
{
EnsureNotRateLimited(httpResponseMessage);
httpResponseMessage.EnsureSuccessStatusCode();
}
}
private async Task EnsureTokenValidAsync(CancellationToken ct)
{
if ((object)_token == null)
{
throw new InvalidOperationException("Client not initialized. Call InitializeAsync first.");
}
if (_token.IsExpired)
{
await ForceRefreshAsync(ct);
}
}
private async Task ForceRefreshAsync(CancellationToken ct)
{
try
{
_token = await <auth>P.RefreshAsync(_clientId, _token, ct);
}
catch (SpotifyAuthException ex) when (ex.Error == "invalid_grant")
{
<storage>P.Clear();
_token = await <auth>P.AuthorizeAsync(_clientId, ct);
}
<storage>P.Save(_token);
}
private static void EnsureNotRateLimited(HttpResponseMessage response)
{
if (response.StatusCode != HttpStatusCode.TooManyRequests)
{
return;
}
response.Headers.TryGetValues("Retry-After", out IEnumerable<string> values);
string rawRetryAfter = values?.FirstOrDefault();
TimeSpan timeSpan = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow) ?? TimeSpan.FromSeconds(10.0);
if (timeSpan < TimeSpan.FromSeconds(1.0))
{
timeSpan = TimeSpan.FromSeconds(1.0);
}
throw new SpotifyRateLimitException(timeSpan, rawRetryAfter);
}
private static TrackInfo ParseTrack(JToken item)
{
string id = Extensions.Value<string>((IEnumerable<JToken>)item[(object)"id"]);
string title = Extensions.Value<string>((IEnumerable<JToken>)item[(object)"name"]);
IEnumerable<string> values = ((IEnumerable<JToken>)item[(object)"artists"]).Select((JToken a) => Extensions.Value<string>((IEnumerable<JToken>)a[(object)"name"]));
string artist = string.Join(", ", values);
string album = Extensions.Value<string>((IEnumerable<JToken>)item[(object)"album"][(object)"name"]);
int durationMs = Extensions.Value<int>((IEnumerable<JToken>)item[(object)"duration_ms"]);
JToken obj = item[(object)"album"][(object)"images"];
object obj2;
if (obj == null)
{
obj2 = null;
}
else
{
JToken? obj3 = ((IEnumerable<JToken>)obj).FirstOrDefault();
if (obj3 == null)
{
obj2 = null;
}
else
{
JToken obj4 = obj3[(object)"url"];
obj2 = ((obj4 != null) ? Extensions.Value<string>((IEnumerable<JToken>)obj4) : null);
}
}
string coverUrl = (string)obj2;
return new TrackInfo(id, title, artist, album, coverUrl, durationMs);
}
}
public record SpotifyToken(string AccessToken, string RefreshToken, int ExpiresIn, DateTime ObtainedAt)
{
public bool IsExpired => DateTime.UtcNow >= ObtainedAt.AddSeconds(ExpiresIn - 30);
}
public record TrackInfo(string Id, string Title, string Artist, string Album, string? CoverUrl, int DurationMs, byte[]? CoverBytes = null);
public record PlaybackState(TrackInfo? Track, bool IsPlaying, int ProgressMs, string? StatusMessage = null)
{
public static PlaybackState Empty => new PlaybackState(null, IsPlaying: false, 0);
}
public class TokenStorage
{
private class StoredToken
{
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
public int ExpiresIn { get; set; }
public DateTime ObtainedAt { get; set; }
public StoredToken()
{
}
public StoredToken(string access, string refresh, int expiresIn, DateTime obtainedAt)
{
AccessToken = access;
RefreshToken = refresh;
ExpiresIn = expiresIn;
ObtainedAt = obtainedAt;
}
}
private static class NativeMethods
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CREDENTIAL
{
public uint Flags;
public uint Type;
public string TargetName;
public string? Comment;
public long LastWritten;
public uint CredentialBlobSize;
public IntPtr CredentialBlob;
public uint Persist;
public uint AttributeCount;
public IntPtr Attributes;
public string? TargetAlias;
public string? UserName;
}
public const uint CRED_TYPE_GENERIC = 1u;
public const uint CRED_PERSIST_LOCAL_MACHINE = 2u;
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool CredWrite(ref CREDENTIAL credential, uint flags);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential);
[DllImport("advapi32.dll")]
public static extern void CredFree(IntPtr buffer);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode)]
public static extern bool CredDelete(string target, uint type, uint flags);
}
private readonly string CredentialName;
public TokenStorage(string credentialName = "WKMusic/SpotifyToken")
{
CredentialName = credentialName;
base..ctor();
}
public void Save(SpotifyToken token)
{
string s = JsonConvert.SerializeObject((object)new StoredToken(token.AccessToken, token.RefreshToken, token.ExpiresIn, token.ObtainedAt));
byte[] bytes = Encoding.UTF8.GetBytes(s);
NativeMethods.CREDENTIAL cREDENTIAL = default(NativeMethods.CREDENTIAL);
cREDENTIAL.Type = 1u;
cREDENTIAL.TargetName = CredentialName;
cREDENTIAL.CredentialBlob = Marshal.AllocHGlobal(bytes.Length);
cREDENTIAL.CredentialBlobSize = (uint)bytes.Length;
cREDENTIAL.Persist = 2u;
cREDENTIAL.UserName = "spotify";
NativeMethods.CREDENTIAL credential = cREDENTIAL;
Marshal.Copy(bytes, 0, credential.CredentialBlob, bytes.Length);
try
{
if (!NativeMethods.CredWrite(ref credential, 0u))
{
throw new Exception($"CredWrite failed: {Marshal.GetLastWin32Error()}");
}
}
finally
{
Marshal.FreeHGlobal(credential.CredentialBlob);
}
}
public SpotifyToken? Load()
{
if (!NativeMethods.CredRead(CredentialName, 1u, 0u, out var credential))
{
return null;
}
try
{
NativeMethods.CREDENTIAL cREDENTIAL = Marshal.PtrToStructure<NativeMethods.CREDENTIAL>(credential);
byte[] array = new byte[cREDENTIAL.CredentialBlobSize];
Marshal.Copy(cREDENTIAL.CredentialBlob, array, 0, array.Length);
StoredToken storedToken = JsonConvert.DeserializeObject<StoredToken>(Encoding.UTF8.GetString(array));
if (storedToken == null)
{
return null;
}
return new SpotifyToken(storedToken.AccessToken, storedToken.RefreshToken, storedToken.ExpiresIn, storedToken.ObtainedAt);
}
catch
{
Clear();
return null;
}
finally
{
NativeMethods.CredFree(credential);
}
}
public void Clear()
{
NativeMethods.CredDelete(CredentialName, 1u, 0u);
}
}
}