diff --git a/CHANGELOG.md b/CHANGELOG.md index 431d621c5..d6efa8e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ## v2.1.1 +### Added +- Discord Rich Presence support can now be enabled in Settings + ### Fixed - Launch Page selected package now persists in settings diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 70f5dcc1f..8ea16fa54 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -149,7 +149,7 @@ private void ShowMainWindow() mainWindow.ExtendClientAreaChromeHints = Program.Args.NoWindowChromeEffects ? ExtendClientAreaChromeHints.NoChrome : ExtendClientAreaChromeHints.PreferSystemChrome; - + var settingsManager = Services.GetRequiredService(); var windowSettings = settingsManager.Settings.WindowSettings; if (windowSettings != null && !Program.Args.ResetWindowPosition) @@ -207,6 +207,7 @@ internal static void ConfigurePageViewModels(IServiceCollection services) services.AddSingleton(provider => new MainWindowViewModel(provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService>()) { Pages = @@ -323,6 +324,11 @@ private static IServiceCollection ConfigureServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + // Rich presence + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); Config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 02c14878b..3137f0cc0 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -81,7 +81,8 @@ public static void Initialize() .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); // Placeholder services that nobody should need during design time services diff --git a/StabilityMatrix.Avalonia/DesignData/MockDiscordRichPresenceService.cs b/StabilityMatrix.Avalonia/DesignData/MockDiscordRichPresenceService.cs new file mode 100644 index 000000000..79851cd79 --- /dev/null +++ b/StabilityMatrix.Avalonia/DesignData/MockDiscordRichPresenceService.cs @@ -0,0 +1,18 @@ +using System; +using StabilityMatrix.Avalonia.Services; + +namespace StabilityMatrix.Avalonia.DesignData; + +public class MockDiscordRichPresenceService : IDiscordRichPresenceService +{ + /// + public void Dispose() + { + GC.SuppressFinalize(this); + } + + /// + public void UpdateState() + { + } +} diff --git a/StabilityMatrix.Avalonia/Services/DiscordRichPresenceService.cs b/StabilityMatrix.Avalonia/Services/DiscordRichPresenceService.cs new file mode 100644 index 000000000..5efd5e8b9 --- /dev/null +++ b/StabilityMatrix.Avalonia/Services/DiscordRichPresenceService.cs @@ -0,0 +1,176 @@ +using System; +using DiscordRPC; +using DiscordRPC.Logging; +using DiscordRPC.Message; +using Microsoft.Extensions.Logging; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.Services; + +public class DiscordRichPresenceService : IDiscordRichPresenceService +{ + private const string ApplicationId = "1134669805237059615"; + + private readonly ILogger logger; + private readonly ISettingsManager settingsManager; + private readonly DiscordRpcClient client; + private readonly string appDetails; + private bool isDisposed; + + private RichPresence DefaultPresence => new() + { + Details = appDetails, + Assets = new DiscordRPC.Assets + { + LargeImageKey = "stabilitymatrix-logo-1", + LargeImageText = $"Stability Matrix {appDetails}", + }, + Buttons = new[] + { + new Button + { + Label = "GitHub", + Url = "https://github.com/LykosAI/StabilityMatrix", + } + } + }; + + public DiscordRichPresenceService( + ILogger logger, + ISettingsManager settingsManager) + { + this.logger = logger; + this.settingsManager = settingsManager; + + appDetails = $"v{Compat.AppVersion.WithoutMetadata()}"; + + client = new DiscordRpcClient(ApplicationId); + client.Logger = new NullLogger(); + client.OnReady += OnReady; + client.OnError += OnError; + client.OnClose += OnClose; + client.OnPresenceUpdate += OnPresenceUpdate; + + settingsManager.SettingsPropertyChanged += (sender, args) => + { + if (args.PropertyName == nameof(settingsManager.Settings.IsDiscordRichPresenceEnabled)) + { + UpdateState(); + } + }; + + EventManager.Instance.RunningPackageStatusChanged += OnRunningPackageStatusChanged; + } + + private void OnReady(object sender, ReadyMessage args) + { + logger.LogInformation("Received Ready from user {User}", args.User.Username); + } + + private void OnError(object sender, ErrorMessage args) + { + logger.LogWarning("Received Error: {Message}", args.Message); + } + + private void OnClose(object sender, CloseMessage args) + { + logger.LogInformation("Received Close: {Reason}", args.Reason); + } + + private void OnPresenceUpdate(object sender, PresenceMessage args) + { + logger.LogDebug("Received Update: {Presence}", args.Presence.ToString()); + } + + private void OnRunningPackageStatusChanged(object? sender, RunningPackageStatusChangedEventArgs args) + { + if (!client.IsInitialized || !settingsManager.Settings.IsDiscordRichPresenceEnabled) return; + + if (args.CurrentPackagePair is null) + { + client.SetPresence(DefaultPresence); + } + else + { + var presence = DefaultPresence; + + var packageTitle = args.CurrentPackagePair.BasePackage switch + { + A3WebUI => "Automatic1111 Web UI", + VladAutomatic => "SD.Next Web UI", + ComfyUI => "ComfyUI", + VoltaML => "VoltaML", + InvokeAI => "InvokeAI", + _ => "Stable Diffusion" + }; + + presence.State = $"Running {packageTitle}"; + + presence.Assets.SmallImageText = presence.State; + presence.Assets.SmallImageKey = args.CurrentPackagePair.BasePackage switch + { + ComfyUI => "fa_diagram_project", + VoltaML => "ic_package_voltaml", + InvokeAI => "ic_package_invokeai", + _ => "ic_fluent_box_512_filled" + }; + + presence.WithTimestamps(new Timestamps + { + StartUnixMilliseconds = (ulong?) DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }); + + client.SetPresence(presence); + } + } + + public void UpdateState() + { + // Set initial rich presence + if (settingsManager.Settings.IsDiscordRichPresenceEnabled) + { + lock (client) + { + if (!client.IsInitialized) + { + client.Initialize(); + client.SetPresence(DefaultPresence); + } + } + } + else + { + lock (client) + { + if (client.IsInitialized) + { + client.ClearPresence(); + client.Deinitialize(); + } + } + } + } + + public void Dispose() + { + if (!isDisposed) + { + if (client.IsInitialized) + { + client.ClearPresence(); + } + client.Dispose(); + EventManager.Instance.RunningPackageStatusChanged -= OnRunningPackageStatusChanged; + } + + isDisposed = true; + GC.SuppressFinalize(this); + } + + ~DiscordRichPresenceService() + { + Dispose(); + } +} diff --git a/StabilityMatrix.Avalonia/Services/IDiscordRichPresenceService.cs b/StabilityMatrix.Avalonia/Services/IDiscordRichPresenceService.cs new file mode 100644 index 000000000..62a958511 --- /dev/null +++ b/StabilityMatrix.Avalonia/Services/IDiscordRichPresenceService.cs @@ -0,0 +1,8 @@ +using System; + +namespace StabilityMatrix.Avalonia.Services; + +public interface IDiscordRichPresenceService : IDisposable +{ + public void UpdateState(); +} diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index 8effc08ce..afa39c2bb 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -8,7 +8,7 @@ app.manifest true ./Assets/Icon.ico - 2.1.0-dev.1 + 2.1.1-dev.1 $(Version) true @@ -24,6 +24,7 @@ + diff --git a/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs index d79477188..fe4f1491d 100644 --- a/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs @@ -249,6 +249,8 @@ protected virtual async Task LaunchImpl(string? command) await basePackage.RunPackage(packagePath, command, userArgsString); RunningPackage = basePackage; + + EventManager.Instance.OnRunningPackageStatusChanged(new PackagePair(activeInstall, basePackage)); } // Unpacks sitecustomize.py to the target venv @@ -388,6 +390,7 @@ public void OpenWebUi() private void OnProcessExited(object? sender, int exitCode) { + EventManager.Instance.OnRunningPackageStatusChanged(null); Dispatcher.UIThread.InvokeAsync(async () => { logger.LogTrace("Process exited ({Code}) at {Time:g}", diff --git a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs index bf2bc61fd..2c906c687 100644 --- a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs @@ -23,6 +23,7 @@ public partial class MainWindowViewModel : ViewModelBase { private readonly ISettingsManager settingsManager; private readonly ServiceManager dialogFactory; + private readonly IDiscordRichPresenceService discordRichPresenceService; public string Greeting => "Welcome to Avalonia!"; [ObservableProperty] @@ -40,10 +41,14 @@ public partial class MainWindowViewModel : ViewModelBase public ProgressManagerViewModel ProgressManagerViewModel { get; init; } public UpdateViewModel UpdateViewModel { get; init; } - public MainWindowViewModel(ISettingsManager settingsManager, ServiceManager dialogFactory) + public MainWindowViewModel( + ISettingsManager settingsManager, + IDiscordRichPresenceService discordRichPresenceService, + ServiceManager dialogFactory) { this.settingsManager = settingsManager; this.dialogFactory = dialogFactory; + this.discordRichPresenceService = discordRichPresenceService; ProgressManagerViewModel = dialogFactory.Get(); UpdateViewModel = dialogFactory.Get(); @@ -74,6 +79,9 @@ public override async Task OnLoadedAsync() return; } + // Initialize Discord Rich Presence (this needs LibraryDir so is set here) + discordRichPresenceService.UpdateState(); + // Index checkpoints if we dont have Task.Run(() => settingsManager.IndexCheckpoints()).SafeFireAndForget(); diff --git a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs index 378bbcdc1..fcf3cd295 100644 --- a/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/SettingsViewModel.cs @@ -44,6 +44,7 @@ public partial class SettingsViewModel : PageViewModelBase private readonly IPrerequisiteHelper prerequisiteHelper; private readonly IPyRunner pyRunner; private readonly ServiceManager dialogFactory; + public SharedState SharedState { get; } public override string Title => "Settings"; @@ -66,6 +67,9 @@ public partial class SettingsViewModel : PageViewModelBase // Shared folder options [ObservableProperty] private bool removeSymlinksOnShutdown; + // Integrations section + [ObservableProperty] private bool isDiscordRichPresenceEnabled; + // Debug section [ObservableProperty] private string? debugPaths; [ObservableProperty] private string? debugCompatInfo; @@ -90,14 +94,19 @@ public SettingsViewModel( this.prerequisiteHelper = prerequisiteHelper; this.pyRunner = pyRunner; this.dialogFactory = dialogFactory; + SharedState = sharedState; - + SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1]; RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown; settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme); + + settingsManager.RelayPropertyFor(this, + vm => vm.IsDiscordRichPresenceEnabled, + settings => settings.IsDiscordRichPresenceEnabled); } partial void OnSelectedThemeChanged(string? value) diff --git a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml index 114d64f65..7994c76b8 100644 --- a/StabilityMatrix.Avalonia/Views/SettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/SettingsPage.axaml @@ -6,7 +6,7 @@ xmlns:ui="using:FluentAvalonia.UI.Controls" xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData" xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="700" x:DataType="vm:SettingsViewModel" x:CompileBindings="True" d:DataContext="{x:Static mocks:DesignData.SettingsViewModel}" @@ -53,7 +53,7 @@ Description="Select this option if you're having problems moving Stability Matrix to another drive" Margin="8,8"> - @@ -108,6 +108,28 @@ + + + + + + + + + + + + + + + PackageLaunchRequested; public event EventHandler? ScrollToBottomRequested; public event EventHandler? ProgressChanged; + public event EventHandler? RunningPackageStatusChanged; public void OnGlobalProgressChanged(int progress) => GlobalProgressChanged?.Invoke(this, progress); public void RequestPageChange(Type pageType) => PageChangeRequested?.Invoke(this, pageType); @@ -36,4 +40,6 @@ public void OnScrollToBottomRequested() => ScrollToBottomRequested?.Invoke(this, EventArgs.Empty); public void OnProgressChanged(ProgressItem progress) => ProgressChanged?.Invoke(this, progress); + public void OnRunningPackageStatusChanged(PackagePair? currentPackagePair) => + RunningPackageStatusChanged?.Invoke(this, new RunningPackageStatusChangedEventArgs(currentPackagePair)); } diff --git a/StabilityMatrix.Core/Models/PackagePair.cs b/StabilityMatrix.Core/Models/PackagePair.cs new file mode 100644 index 000000000..dd5a0897c --- /dev/null +++ b/StabilityMatrix.Core/Models/PackagePair.cs @@ -0,0 +1,9 @@ +using StabilityMatrix.Core.Models.Packages; + +namespace StabilityMatrix.Core.Models; + + +/// +/// Pair of InstalledPackage and BasePackage +/// +public record PackagePair(InstalledPackage InstalledPackage, BasePackage BasePackage); diff --git a/StabilityMatrix.Core/Models/Settings/Settings.cs b/StabilityMatrix.Core/Models/Settings/Settings.cs index 6948e6b0e..cf82886bd 100644 --- a/StabilityMatrix.Core/Models/Settings/Settings.cs +++ b/StabilityMatrix.Core/Models/Settings/Settings.cs @@ -45,6 +45,8 @@ public InstalledPackage? ActiveInstalledPackage public bool RemoveFolderLinksOnShutdown { get; set; } + public bool IsDiscordRichPresenceEnabled { get; set; } + public Dictionary? EnvironmentVariables { get; set; } public HashSet? InstalledModelHashes { get; set; } = new();