diff --git a/StreamdeckLib/Models/SetStateRequest.cs b/StreamdeckLib/Models/SetStateRequest.cs index 8b294cf..f141e99 100644 --- a/StreamdeckLib/Models/SetStateRequest.cs +++ b/StreamdeckLib/Models/SetStateRequest.cs @@ -1,6 +1,6 @@ namespace StreamDeckLib.Models { - record SetStateRequest : ContextMessage + public record SetStateRequest : ContextMessage { public override string Event => "setState"; public StatePayload Payload { get; set; } diff --git a/StreamdeckLib/Models/StatePayload.cs b/StreamdeckLib/Models/StatePayload.cs index f303bdf..97b38ce 100644 --- a/StreamdeckLib/Models/StatePayload.cs +++ b/StreamdeckLib/Models/StatePayload.cs @@ -1,6 +1,6 @@ namespace StreamDeckLib.Models { - public record StatePayload(uint Payload); + public record StatePayload(uint State); record TitlePayload(string? Title, int Target, int State); } \ No newline at end of file diff --git a/StreamdeckLib/StreamDeckConnection.cs b/StreamdeckLib/StreamDeckConnection.cs index db52092..3c5dab8 100644 --- a/StreamdeckLib/StreamDeckConnection.cs +++ b/StreamdeckLib/StreamDeckConnection.cs @@ -96,10 +96,10 @@ private async Task ReceiveAsync() textBuffer.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); if (!result.EndOfMessage) continue; - _logger.LogDebug("Got JSON data: {ToString}", textBuffer.ToString()); + //_logger.LogDebug("Got JSON data: {ToString}", textBuffer.ToString()); var message = JsonConvert.DeserializeObject(textBuffer.ToString(), new StreamDeckMessageConverter()); - _logger.LogDebug("Got message of {Event} JSON data: {ToString}", message?.Event, textBuffer.ToString()); + //_logger.LogDebug("Got message of {Event} JSON data: {ToString}", message?.Event, textBuffer.ToString()); switch (message) { @@ -190,7 +190,7 @@ public async Task SendMessage(EventMessage message) { await _send.WaitAsync(); var json = JsonConvert.SerializeObject(message, new JsonSerializerSettings() { ContractResolver = new DefaultContractResolver() { NamingStrategy = new CamelCaseNamingStrategy() }, }); - _logger.LogDebug("Sending message of {Type} with JSON data {Json}", message.Event, json); + //_logger.LogDebug("Sending message of {Type} with JSON data {Json}", message.Event, json); var buf = Encoding.UTF8.GetBytes(json); await _socket.SendAsync(new ArraySegment(buf), WebSocketMessageType.Text, true, _cancelSource.Token); diff --git a/VTubeStudioAPI/Events/HotkeyTriggeredEvent.cs b/VTubeStudioAPI/Events/HotkeyTriggeredEvent.cs new file mode 100644 index 0000000..81d0cdd --- /dev/null +++ b/VTubeStudioAPI/Events/HotkeyTriggeredEvent.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; + +namespace Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Events; + +public class HotkeyTriggeredEvent +{ + [JsonProperty("hotkeyID")] + public required string Id { get; set; } + + [JsonProperty("hotkeyName")] + public required string Name { get; set; } + + [JsonProperty("hotkeyAction")] + public required string Action { get; set; } + + [JsonProperty("hotkeyFile")] + public required string File { get; set; } + + [JsonProperty("hotkeyTriggeredByAPI")] + public bool ApiTriggered { get; set; } + + [JsonProperty("modelID")] + public required string ModelId { get; set; } + + [JsonProperty("modelName")] + public required string ModelName { get; set; } + + [JsonProperty("isLive2DItem")] + public bool IsLive2DItem { get; set; } +} diff --git a/VTubeStudioAPI/Models/Expression.cs b/VTubeStudioAPI/Models/Expression.cs new file mode 100644 index 0000000..b17783c --- /dev/null +++ b/VTubeStudioAPI/Models/Expression.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace VTubeStudioAPI.Models; + +public class Expression +{ + [JsonProperty("name")] + public required string Name { get; set; } + + [JsonProperty("file")] + public required string File { get; set; } + + [JsonProperty("active")] + public bool Active { get; set; } + + [JsonProperty("deactivateWhenKeyIsLetGo")] + public bool DeactivateWhenKeyIsLetGo { get; set; } + + [JsonProperty("autoDeactivateAfterSeconds")] + public bool AutoDeactivateAfterSeconds { get; set; } + + [JsonProperty("secondsRemaining")] + public int SecondsRemaining { get; set; } +} diff --git a/VTubeStudioAPI/Requests/ApiRequest.cs b/VTubeStudioAPI/Requests/ApiRequest.cs index 3e62822..fecde5d 100644 --- a/VTubeStudioAPI/Requests/ApiRequest.cs +++ b/VTubeStudioAPI/Requests/ApiRequest.cs @@ -1,8 +1,10 @@ using Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Responses; +using Newtonsoft.Json; namespace Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Requests; public abstract class ApiRequest { + [JsonIgnore] public abstract RequestType MessageType { get; } } \ No newline at end of file diff --git a/VTubeStudioAPI/Requests/ExpressionActivationRequest.cs b/VTubeStudioAPI/Requests/ExpressionActivationRequest.cs new file mode 100644 index 0000000..18b589a --- /dev/null +++ b/VTubeStudioAPI/Requests/ExpressionActivationRequest.cs @@ -0,0 +1,16 @@ +using Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Responses; +using Newtonsoft.Json; + +namespace Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Requests; + +public class ExpressionActivationRequest(string? expressionFile = null, bool activate = true, float fadeTime = 0.5f) : ApiRequest +{ + [JsonProperty("expressionFile")] + public string? ExpressionFile { get; set; } = expressionFile; + [JsonProperty("active")] + public bool? Activate { get; set; } = activate; + [JsonProperty("fadeTime")] + public float FadeTime { get; set; } = fadeTime; + + public override RequestType MessageType { get; } = RequestType.ExpressionActivationRequest; +} diff --git a/VTubeStudioAPI/Requests/ExpressionStateRequest.cs b/VTubeStudioAPI/Requests/ExpressionStateRequest.cs new file mode 100644 index 0000000..8411dbb --- /dev/null +++ b/VTubeStudioAPI/Requests/ExpressionStateRequest.cs @@ -0,0 +1,10 @@ +using Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Responses; + +namespace Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Requests; + +public class ExpressionStateRequest(string? expressionFile = null) : ApiRequest +{ + public string? ExpressionFile { get; set; } = expressionFile; + + public override RequestType MessageType { get; } = RequestType.ExpressionStateRequest; +} \ No newline at end of file diff --git a/VTubeStudioAPI/Responses/ExpressionStateResponse.cs b/VTubeStudioAPI/Responses/ExpressionStateResponse.cs new file mode 100644 index 0000000..aa322d4 --- /dev/null +++ b/VTubeStudioAPI/Responses/ExpressionStateResponse.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using VTubeStudioAPI.Models; + +namespace VTubeStudioAPI.Responses; + +public class ExpressionStateResponse +{ + [JsonProperty("modelLoaded")] + public bool ModelLoaded { get; set; } + + [JsonProperty("modelName")] + public required string ModelName { get; set; } + + [JsonProperty("modelID")] + public required string ModelId { get; set; } + + [JsonProperty("expressions")] + public required List Expressions { get; set; } +} \ No newline at end of file diff --git a/VTubeStudioAPI/Responses/ResponseType.cs b/VTubeStudioAPI/Responses/ResponseType.cs index fdacec6..d9638a4 100644 --- a/VTubeStudioAPI/Responses/ResponseType.cs +++ b/VTubeStudioAPI/Responses/ResponseType.cs @@ -23,6 +23,8 @@ public enum RequestType ParameterDeletionRequest, InjectParameterDataRequest, EventSubscriptionRequest, + ExpressionStateRequest, + ExpressionActivationRequest, } public enum ResponseType @@ -52,6 +54,8 @@ public enum ResponseType ModelMovedEvent, ModelConfigChangedEvent, HotkeyTriggeredEvent, + ExpressionStateResponse, + ExpressionActivationResponse, } /// diff --git a/VTubeStudioAPI/VTubeStudioWebsocketClient.cs b/VTubeStudioAPI/VTubeStudioWebsocketClient.cs index 5a5f8c9..9b92edf 100644 --- a/VTubeStudioAPI/VTubeStudioWebsocketClient.cs +++ b/VTubeStudioAPI/VTubeStudioWebsocketClient.cs @@ -4,6 +4,7 @@ using Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Responses; using Microsoft.Extensions.Logging; using Newtonsoft.Json; +using VTubeStudioAPI.Responses; using WebSocketSharp; namespace Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi; @@ -42,7 +43,7 @@ private void HandleAuthResponse(object? sender, ApiEventArgs("ModelMovedEvent")); Send(new EventSubscriptionRequest("ModelConfigChangedEvent")); - // Send(new EventSubscriptionRequest("HotkeyTriggeredEvent")); + Send(new EventSubscriptionRequest("HotkeyTriggeredEvent")); } public void Send(ApiRequest request, string? requestId = null) @@ -77,7 +78,7 @@ public void SetConnection(string host, ushort port) public void ConnectIfNeeded() { - _logger.LogDebug("Ws is null? {WsNull}, ws alive? {WsAlive}, trying to connect? {TryingToConnect}", _ws == null, _ws?.IsAlive, _tryingToConnect); + //_logger.LogDebug("Ws is null? {WsNull}, ws alive? {WsAlive}, trying to connect? {TryingToConnect}", _ws == null, _ws?.IsAlive, _tryingToConnect); if (_ws is not null && _ws.IsAlive) return; if (_tryingToConnect) return; @@ -87,6 +88,7 @@ public void ConnectIfNeeded() { Connect(); _tryingToConnect = false; + _tryingToConnect = false; } ); } @@ -131,6 +133,7 @@ private void MessageReceived(object? sender, MessageEventArgs e) var response = JsonConvert.DeserializeObject(e.Data); if (response is null) return; + _logger.LogDebug("Got message: {JsonString}", e.Data); _logger.LogDebug("Got message of type: {Type}", response.MessageType); switch (response.MessageType) @@ -192,9 +195,15 @@ private void MessageReceived(object? sender, MessageEventArgs e) case ResponseType.ModelConfigChangedEvent: OnModelConfigChangedEvent?.Invoke(this, new (response.Data!.ToObject()!)); break; - // case ResponseType.HotkeyTriggeredEvent: - // OnHotkeyTriggeredEvent?.Invoke(this, new (response.Data!.ToObject()!)); - // break; + case ResponseType.HotkeyTriggeredEvent: + OnHotkeyTriggeredEvent?.Invoke(this, new (response.Data!.ToObject()!)); + break; + case ResponseType.ExpressionStateResponse: + OnExpressionState?.Invoke(this, new (response.Data!.ToObject()!)); + break; + case ResponseType.ExpressionActivationResponse: + OnExpressionActivation?.Invoke(this, EventArgs.Empty); + break; default: throw new ArgumentOutOfRangeException(); } } @@ -234,5 +243,8 @@ public void Reconnect() public static event EventHandler>? OnCurrentModelInformation; public static event EventHandler>? OnModelMove; public static event EventHandler>? OnModelConfigChangedEvent; + public static event EventHandler>? OnHotkeyTriggeredEvent; + public static event EventHandler>? OnExpressionState; + public static event EventHandler? OnExpressionActivation; #endregion } \ No newline at end of file diff --git a/streamdeck-vtubestudio/Actions/ExpressionToggleAction.cs b/streamdeck-vtubestudio/Actions/ExpressionToggleAction.cs new file mode 100644 index 0000000..077ee69 --- /dev/null +++ b/streamdeck-vtubestudio/Actions/ExpressionToggleAction.cs @@ -0,0 +1,72 @@ +#nullable enable +using System.Linq; +using Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi; +using Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Requests; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using StreamDeckLib; +using StreamDeckLib.Models; + +namespace Cazzar.StreamDeck.VTubeStudio.Actions; + + +[StreamDeckAction("dev.cazzar.vtubestudio.expressiontoggle")] +public class ExpressionToggleAction : BaseAction +{ + private readonly ExpressionCache _expressionCache; + + public class ExpressionTogglePayload + { + [JsonProperty("expression")] + public string? Expression { get; set; } = "Cat Tail.exp3.json"; + + [JsonProperty("fadeTime")] + public float FadeTime { get; set; } = 0.5f; + } + + public ExpressionToggleAction(GlobalSettingsManager gsm, VTubeStudioWebsocketClient vts, IStreamDeckConnection isd, ILogger logger, ExpressionCache expressionCache) : base(gsm, vts, isd, logger) + { + _expressionCache = expressionCache; + } + +protected override void Pressed() +{ + if (!_expressionCache.ModelLoaded) + return; + + var activate = !_expressionCache.Expressions.FirstOrDefault(e => e.File == Settings.Expression)?.Active ?? false; + + Vts.Send(new ExpressionActivationRequest(Settings.Expression, activate, Settings.FadeTime)); + + Connection.SendMessage(new SetStateRequest + { + Payload = new ((uint) (activate ? 1 : 0)), + Context = ContextId, + }); +} + + public override void Tick() + { + base.Tick(); + var activate = !_expressionCache.Expressions.FirstOrDefault(e => e.File == Settings.Expression)?.Active ?? false; + + Connection.SendMessage(new SetStateRequest + { + Payload = new ((uint) (activate ? 1 : 0)), + Context = ContextId, + }); + } + + protected override object GetClientData() => new + { + expressions = _expressionCache.Expressions.DistinctBy(e => e.File).Select(e => new { e.Name, e.File }), + }; + + protected override void Released() + { + } + + protected override void SettingsUpdated(ExpressionTogglePayload oldSettings, ExpressionTogglePayload newSettings) + { + } +} \ No newline at end of file diff --git a/streamdeck-vtubestudio/ExpressionCache.cs b/streamdeck-vtubestudio/ExpressionCache.cs new file mode 100644 index 0000000..e825712 --- /dev/null +++ b/streamdeck-vtubestudio/ExpressionCache.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Threading; +using Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi; +using Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Events; +using Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Requests; +using Cazzar.StreamDeck.VTubeStudio.VTubeStudioApi.Responses; +using Microsoft.Extensions.Logging; +using VTubeStudioAPI.Models; +using VTubeStudioAPI.Responses; + +namespace Cazzar.StreamDeck.VTubeStudio; + +public class ExpressionCache +{ + private readonly VTubeStudioWebsocketClient _vts; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _semaphore = new (1, 1); + + private static readonly List> instances = new(); + + public ReadOnlyCollection Expressions { get; set; } = new List().AsReadOnly(); + public string LastModelId { get; set; } = string.Empty; + public string LastModelName { get; set; } = string.Empty; + public bool ModelLoaded { get; set; } = false; + + + public ExpressionCache(ModelCache modelCache, VTubeStudioWebsocketClient vts, ILogger logger) + { + instances.Add(new(this)); + _vts = vts; + _logger = logger; + VTubeStudioWebsocketClient.OnExpressionState += OnExpressionState; + VTubeStudioWebsocketClient.OnModelLoad += Refresh; + VTubeStudioWebsocketClient.OnHotkeyTriggeredEvent += RefreshHotkey; + VTubeStudioWebsocketClient.OnCurrentModelInformation += Refresh; + VTubeStudioWebsocketClient.OnAuthenticationResponse += HandleAuthenticated; + VTubeStudioWebsocketClient.OnExpressionActivation += Refresh; + } + private void HandleAuthenticated(object sender, ApiEventArgs e) + { + if (!e.Response.Authenticated) + return; + + Refresh(sender, e); + } + + private void RefreshHotkey(object sender, ApiEventArgs e) + { + if (e.Response.Action != "ToggleExpression") return; + + Refresh(sender, e); + } + + private void Refresh(object sender, object e) + { + _vts.Send(new ExpressionStateRequest()); + } + + private async void OnExpressionState(object sender, ApiEventArgs e) + { + try + { + await _semaphore.WaitAsync(); + + Expressions = e.Response.Expressions.AsReadOnly(); + LastModelId = e.Response.ModelId; + LastModelName = e.Response.ModelName; + ModelLoaded = e.Response.ModelLoaded; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating expression cache"); + } + finally + { + _semaphore.Release(); + } + } + + ~ExpressionCache() + { + ~ExpressionCache() + { + instances.RemoveAll(wr => !wr.TryGetTarget(out _) || + (wr.TryGetTarget(out var cache) && cache == this)); + VTubeStudioWebsocketClient.OnExpressionState -= OnExpressionState; + VTubeStudioWebsocketClient.OnModelLoad -= Refresh; + VTubeStudioWebsocketClient.OnHotkeyTriggeredEvent -= RefreshHotkey; + VTubeStudioWebsocketClient.OnCurrentModelInformation -= Refresh; + VTubeStudioWebsocketClient.OnAuthenticationResponse -= HandleAuthenticated; + } + VTubeStudioWebsocketClient.OnExpressionState -= OnExpressionState; + VTubeStudioWebsocketClient.OnModelLoad -= Refresh; + VTubeStudioWebsocketClient.OnHotkeyTriggeredEvent -= RefreshHotkey; + VTubeStudioWebsocketClient.OnCurrentModelInformation -= Refresh; + VTubeStudioWebsocketClient.OnAuthenticationResponse -= HandleAuthenticated; + } + + public event EventHandler Updated; +} + +public class ExpressionCacheUpdatedEventArgs : EventArgs +{ + public IDictionary> Expressions { get; init; } +} \ No newline at end of file diff --git a/streamdeck-vtubestudio/Program.cs b/streamdeck-vtubestudio/Program.cs index c7e5822..6ea6b0c 100644 --- a/streamdeck-vtubestudio/Program.cs +++ b/streamdeck-vtubestudio/Program.cs @@ -20,8 +20,8 @@ public static async Task Main(string[] args) #if DEBUG // //get roaming app data folder - // var path = System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData); - // args = await File.ReadAllLinesAsync(Path.Combine(path, @"Elgato\StreamDeck\Plugins\dev.cazzar.streamdeck.vtubestudio.sdPlugin\argv.txt")); + var path = System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData); + args = await File.ReadAllLinesAsync(Path.Combine(path, @"Elgato\StreamDeck\Plugins\dev.cazzar.streamdeck.vtubestudio.sdPlugin\argv.txt")); #endif var hostBuilder = CreateHostBuilder(args); @@ -51,6 +51,7 @@ private static IHostBuilder CreateHostBuilder(string[] args) services.AddSingleton((sp) => sp.GetService()!); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddLogging( logging => { @@ -60,7 +61,10 @@ private static IHostBuilder CreateHostBuilder(string[] args) } ); } - ); + ).UseDefaultServiceProvider((context, options) => + { + options.ValidateOnBuild = true; + }); } } } diff --git a/streamdeck-vtubestudio/PropertyInspector/src/pages/ExpressionToggle/App.vue b/streamdeck-vtubestudio/PropertyInspector/src/pages/ExpressionToggle/App.vue new file mode 100644 index 0000000..62d177c --- /dev/null +++ b/streamdeck-vtubestudio/PropertyInspector/src/pages/ExpressionToggle/App.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/streamdeck-vtubestudio/PropertyInspector/src/pages/ExpressionToggle/main.js b/streamdeck-vtubestudio/PropertyInspector/src/pages/ExpressionToggle/main.js new file mode 100644 index 0000000..5f27c5f --- /dev/null +++ b/streamdeck-vtubestudio/PropertyInspector/src/pages/ExpressionToggle/main.js @@ -0,0 +1,26 @@ +import { createApp } from 'vue' +import StreamDeck from '../../utils/streamdeck'; +import App from './App.vue' +import { createStore } from 'vuex'; + +window.connectElgatoStreamDeckSocket = function (inPort, inPluginUUID, inRegisterEvent, inInfo, inActionInfo) { + let streamDeck = new StreamDeck(inPort, inPluginUUID, inRegisterEvent, inInfo, inActionInfo); + console.log("Connected to stream deck") + + let store = createStore({ + state: { + streamDeck, + }, + mutations: { + }, + actions: { + }, + modules: { + } + }) + + createApp(App).use(store).mount('#app') +} + + + diff --git a/streamdeck-vtubestudio/manifest.json b/streamdeck-vtubestudio/manifest.json index b3b1285..4717d0e 100644 --- a/streamdeck-vtubestudio/manifest.json +++ b/streamdeck-vtubestudio/manifest.json @@ -181,6 +181,26 @@ "Tooltip": "VTubeStudio [Hold To Zoom]", "UUID": "dev.cazzar.vtubestudio.holdtozoom", "PropertyInspectorPath": "PropertyInspector/TempZoom.html" + }, + { + "Icon": "vts_logo_transparent", + "Name": "Expression Toggle", + "States": [ + { + "Image": "vts_logo_transparent", + "TitleAlignment": "bottom", + "FontSize": "10" + }, + { + "Image": "vts_logo_transparent_darker", + "TitleAlignment": "bottom", + "FontSize": "10" + } + ], + "SupportedInMultiActions": true, + "Tooltip": "VTubeStudio [Expression Toggle]", + "UUID": "dev.cazzar.vtubestudio.expressiontoggle", + "PropertyInspectorPath": "PropertyInspector/NoSettings.html" } ], "Author": "Cazzar", diff --git a/streamdeck-vtubestudio/nlog.config b/streamdeck-vtubestudio/nlog.config index b569e0c..b0e7a33 100644 --- a/streamdeck-vtubestudio/nlog.config +++ b/streamdeck-vtubestudio/nlog.config @@ -13,16 +13,17 @@ layout="${longdate}|${level}|${logger}|${message} | ${all-event-properties} ${exception:format=tostring}" maxArchiveFiles="2" archiveOldFileOnStartup="true" archiveEvery="Day" enableArchiveFileCompression="true" /> + --> + - --> - --> \ No newline at end of file diff --git a/streamdeck-vtubestudio/streamdeck-vtubestudio.csproj b/streamdeck-vtubestudio/streamdeck-vtubestudio.csproj index 485b04d..e47754e 100644 --- a/streamdeck-vtubestudio/streamdeck-vtubestudio.csproj +++ b/streamdeck-vtubestudio/streamdeck-vtubestudio.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net9.0 true win-x64 win-x64;osx-x64 diff --git a/streamdeck-vtubestudio/vts_logo_transparent_darker.png b/streamdeck-vtubestudio/vts_logo_transparent_darker.png new file mode 100644 index 0000000..19bc513 Binary files /dev/null and b/streamdeck-vtubestudio/vts_logo_transparent_darker.png differ