diff --git a/VRCOSC.Desktop/VRCOSC.Desktop.csproj b/VRCOSC.Desktop/VRCOSC.Desktop.csproj index 45cf3303..7eb8b918 100644 --- a/VRCOSC.Desktop/VRCOSC.Desktop.csproj +++ b/VRCOSC.Desktop/VRCOSC.Desktop.csproj @@ -1,6 +1,6 @@ - net6.0-windows10.0.22000.0 + net6.0-windows10.0.22621.0 Exe VRCOSC game.ico diff --git a/VRCOSC.Game.Tests/VRCOSC.Game.Tests.csproj b/VRCOSC.Game.Tests/VRCOSC.Game.Tests.csproj index 79e4b418..348067dd 100644 --- a/VRCOSC.Game.Tests/VRCOSC.Game.Tests.csproj +++ b/VRCOSC.Game.Tests/VRCOSC.Game.Tests.csproj @@ -1,7 +1,7 @@  WinExe - net6.0-windows10.0.22000.0 + net6.0-windows10.0.22621.0 false enable diff --git a/VRCOSC.Game/ChatBox/ChatBoxManager.cs b/VRCOSC.Game/ChatBox/ChatBoxManager.cs new file mode 100644 index 00000000..654ccf15 --- /dev/null +++ b/VRCOSC.Game/ChatBox/ChatBoxManager.cs @@ -0,0 +1,331 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Platform; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.ChatBox.Serialisation.V1; +using VRCOSC.Game.Modules; +using VRCOSC.Game.OSC.VRChat; + +namespace VRCOSC.Game.ChatBox; + +public class ChatBoxManager +{ + private const int priority_count = 6; + + private bool sendEnabled; + + public bool SendEnabled + { + get => sendEnabled; + set + { + if (sendEnabled && !value) clearChatBox(); + sendEnabled = value; + } + } + + public readonly Bindable SelectedClip = new(); + + public readonly BindableList Clips = new(); + + public readonly Dictionary> VariableMetadata = new(); + public readonly Dictionary> StateMetadata = new(); + public readonly Dictionary> EventMetadata = new(); + public IReadOnlyDictionary ModuleEnabledCache = null!; + private Bindable sendDelay = null!; + private VRChatOscClient oscClient = null!; + private TimelineSerialiser serialiser = null!; + + public readonly Dictionary<(string, string), string?> VariableValues = new(); + public readonly Dictionary StateValues = new(); + public readonly List<(string, string)> TriggeredEvents = new(); + private readonly object triggeredEventsLock = new(); + + public readonly Bindable TimelineLength = new(TimeSpan.FromMinutes(1)); + public float TimelineResolution => 1f / (float)TimelineLength.Value.TotalSeconds; + + public float CurrentPercentage => ((DateTimeOffset.Now - startTime).Ticks % TimelineLength.Value.Ticks) / (float)TimelineLength.Value.Ticks; + public int CurrentSecond => (int)Math.Floor((DateTimeOffset.Now - startTime).TotalSeconds) % (int)TimelineLength.Value.TotalSeconds; + private bool sendAllowed => nextValidTime <= DateTimeOffset.Now; + + public GameManager GameManager = null!; + + private DateTimeOffset startTime; + private DateTimeOffset nextValidTime; + private bool isClear; + private bool isLoaded; + + public void Load(Storage storage, GameManager gameManager) + { + GameManager = gameManager; + serialiser = new TimelineSerialiser(storage); + + if (storage.Exists(@"chatbox.json")) + loadClipData(); + else + Clips.AddRange(DefaultTimeline.GenerateDefaultTimeline(this)); + + Clips.BindCollectionChanged((_, _) => Save()); + + isLoaded = true; + + Save(); + } + + private void loadClipData() + { + var clips = serialiser.Deserialise()?.Clips; + + if (clips is null) return; + + clips.ForEach(clip => + { + clip.AssociatedModules.ToImmutableList().ForEach(moduleName => + { + if (!StateMetadata.ContainsKey(moduleName)) + { + clip.AssociatedModules.Remove(moduleName); + + clip.States.ToImmutableList().ForEach(clipState => + { + clipState.States.RemoveAll(pair => pair.Module == moduleName); + }); + + clip.Events.RemoveAll(clipEvent => clipEvent.Module == moduleName); + + return; + } + + clip.States.ToImmutableList().ForEach(clipState => + { + clipState.States.RemoveAll(pair => !StateMetadata[pair.Module].ContainsKey(pair.Lookup)); + }); + + clip.Events.RemoveAll(clipEvent => !EventMetadata[clipEvent.Module].ContainsKey(clipEvent.Lookup)); + }); + }); + + clips.ForEach(clip => + { + var newClip = CreateClip(); + + newClip.Enabled.Value = clip.Enabled; + newClip.Name.Value = clip.Name; + newClip.Priority.Value = clip.Priority; + newClip.Start.Value = clip.Start; + newClip.End.Value = clip.End; + + newClip.AssociatedModules.AddRange(clip.AssociatedModules); + + clip.States.ForEach(clipState => + { + var stateData = newClip.GetStateFor(clipState.States.Select(state => state.Module), clipState.States.Select(state => state.Lookup)); + if (stateData is null) return; + + stateData.Enabled.Value = clipState.Enabled; + stateData.Format.Value = clipState.Format; + }); + + clip.Events.ForEach(clipEvent => + { + var eventData = newClip.GetEventFor(clipEvent.Module, clipEvent.Lookup); + if (eventData is null) return; + + eventData.Enabled.Value = clipEvent.Enabled; + eventData.Format.Value = clipEvent.Format; + eventData.Length.Value = clipEvent.Length; + }); + + Clips.Add(newClip); + }); + } + + public void Save() + { + if (!isLoaded) return; + + serialiser.Serialise(Clips.ToList()); + } + + public void Initialise(VRChatOscClient oscClient, Bindable sendDelay, Dictionary moduleEnabledCache) + { + this.oscClient = oscClient; + this.sendDelay = sendDelay; + startTime = DateTimeOffset.Now; + nextValidTime = startTime; + isClear = true; + ModuleEnabledCache = moduleEnabledCache; + + Clips.ForEach(clip => clip.Initialise()); + } + + public Clip CreateClip() + { + var newClip = new Clip(); + newClip.InjectDependencies(this); + return newClip; + } + + public void Update() + { + lock (triggeredEventsLock) + { + Clips.ForEach(clip => clip.Update()); + // Events get handled by clips in the same update cycle they're triggered + TriggeredEvents.Clear(); + } + + if (sendAllowed) evaluateClips(); + } + + public void Shutdown() + { + lock (triggeredEventsLock) { TriggeredEvents.Clear(); } + + VariableValues.Clear(); + StateValues.Clear(); + } + + private void evaluateClips() + { + var validClip = getValidClip(); + handleClip(validClip); + nextValidTime += TimeSpan.FromMilliseconds(sendDelay.Value); + } + + private Clip? getValidClip() + { + for (var i = priority_count - 1; i >= 0; i--) + { + foreach (var clip in Clips.Where(clip => clip.Priority.Value == i)) + { + if (clip.Evalulate()) return clip; + } + } + + return null; + } + + private void handleClip(Clip? clip) + { + if (clip is null) + { + if (!isClear) clearChatBox(); + return; + } + + isClear = false; + sendText(clip.GetFormattedText()); + } + + private void sendText(string text) + { + oscClient.SendValues(VRChatOscConstants.ADDRESS_CHATBOX_INPUT, new List { text, true, false }); + } + + private void clearChatBox() + { + sendText(string.Empty); + isClear = true; + } + + public void SetTyping(bool typing) + { + oscClient.SendValue(VRChatOscConstants.ADDRESS_CHATBOX_TYPING, typing); + } + + public void IncreasePriority(Clip clip) => setPriority(clip, clip.Priority.Value + 1); + public void DecreasePriority(Clip clip) => setPriority(clip, clip.Priority.Value - 1); + + private void setPriority(Clip clip, int priority) + { + if (priority is > priority_count - 1 or < 0) return; + if (Clips.Where(other => other.Priority.Value == priority).Any(clip.Intersects)) return; + + clip.Priority.Value = priority; + } + + public void DeleteClip(Clip clip) => Clips.Remove(clip); + + public void RegisterVariable(string module, string lookup, string name, string format) + { + var variableMetadata = new ClipVariableMetadata + { + Module = module, + Lookup = lookup, + Name = name, + Format = format + }; + + if (!VariableMetadata.ContainsKey(module)) + { + VariableMetadata.Add(module, new Dictionary()); + } + + VariableMetadata[module][lookup] = variableMetadata; + } + + public void SetVariable(string module, string lookup, string? value) + { + VariableValues[(module, lookup)] = value; + } + + public void RegisterState(string module, string lookup, string name, string defaultFormat) + { + var stateMetadata = new ClipStateMetadata + { + Module = module, + Lookup = lookup, + Name = name, + DefaultFormat = defaultFormat + }; + + if (!StateMetadata.ContainsKey(module)) + { + StateMetadata.Add(module, new Dictionary()); + } + + StateMetadata[module][lookup] = stateMetadata; + + if (!StateValues.TryAdd(module, lookup)) + { + StateValues[module] = lookup; + } + } + + public void ChangeStateTo(string module, string lookup) + { + StateValues[module] = lookup; + } + + public void RegisterEvent(string module, string lookup, string name, string defaultFormat, int defaultLength) + { + var eventMetadata = new ClipEventMetadata + { + Module = module, + Lookup = lookup, + Name = name, + DefaultFormat = defaultFormat, + DefaultLength = defaultLength + }; + + if (!EventMetadata.ContainsKey(module)) + { + EventMetadata.Add(module, new Dictionary()); + } + + EventMetadata[module][lookup] = eventMetadata; + } + + public void TriggerEvent(string module, string lookup) + { + lock (triggeredEventsLock) { TriggeredEvents.Add((module, lookup)); } + } +} diff --git a/VRCOSC.Game/ChatBox/Clips/Clip.cs b/VRCOSC.Game/ChatBox/Clips/Clip.cs new file mode 100644 index 00000000..cf23ac73 --- /dev/null +++ b/VRCOSC.Game/ChatBox/Clips/Clip.cs @@ -0,0 +1,299 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; + +namespace VRCOSC.Game.ChatBox.Clips; + +/// +/// Represents a timespan that contains all information the ChatBox will need for displaying +/// +public class Clip +{ + public readonly Bindable Enabled = new(true); + public readonly Bindable Name = new("New Clip"); + public readonly BindableNumber Priority = new(); + public readonly BindableList AssociatedModules = new(); + public readonly Bindable Start = new(); + public readonly Bindable End = new(); + public readonly BindableList States = new(); + public readonly BindableList Events = new(); + + public readonly BindableList AvailableVariables = new(); + public int Length => End.Value - Start.Value; + private ChatBoxManager chatBoxManager = null!; + private readonly Queue eventQueue = new(); + private (ClipEvent, DateTimeOffset)? currentEvent; + private ClipState? currentState; + + public void InjectDependencies(ChatBoxManager chatBoxManager) + { + this.chatBoxManager = chatBoxManager; + AssociatedModules.BindCollectionChanged((_, e) => onAssociatedModulesChanged(e), true); + AssociatedModules.BindCollectionChanged((_, _) => chatBoxManager.Save()); + Enabled.BindValueChanged(_ => chatBoxManager.Save()); + Name.BindValueChanged(_ => chatBoxManager.Save()); + Start.BindValueChanged(_ => chatBoxManager.Save()); + End.BindValueChanged(_ => chatBoxManager.Save()); + States.BindCollectionChanged((_, _) => chatBoxManager.Save()); + Events.BindCollectionChanged((_, _) => chatBoxManager.Save()); + } + + public void Initialise() + { + eventQueue.Clear(); + currentEvent = null; + currentState = null; + } + + public void Update() + { + auditEvents(); + setCurrentEvent(); + } + + private void auditEvents() + { + chatBoxManager.TriggeredEvents.ForEach(moduleEvent => + { + var (module, lookup) = moduleEvent; + + var clipEvents = Events.Where(clipEvent => clipEvent.Module == module && clipEvent.Lookup == lookup).ToList(); + if (!clipEvents.Any()) return; + + var clipEvent = clipEvents.Single(); + if (!clipEvent.Enabled.Value) return; + + if (currentEvent?.Item1.Module == module) + // If the new event and current event are from the same module, overwrite the current event + currentEvent = (clipEvent, DateTimeOffset.Now + TimeSpan.FromSeconds(clipEvent.Length.Value)); + else + // If the new event and current event are from different modules, queue the new event + eventQueue.Enqueue(clipEvent); + }); + } + + private void setCurrentEvent() + { + if (currentEvent is not null && currentEvent.Value.Item2 < DateTimeOffset.Now) currentEvent = null; + + if (currentEvent is null && eventQueue.Any()) + { + var nextEvent = eventQueue.Dequeue(); + currentEvent = (nextEvent, DateTimeOffset.Now + TimeSpan.FromSeconds(nextEvent.Length.Value)); + } + } + + public ClipEvent? GetEventFor(string module, string lookup) + { + try + { + return Events.Single(clipEvent => clipEvent.Module == module && clipEvent.Lookup == lookup); + } + catch (InvalidOperationException) + { + return null; + } + } + + public ClipState? GetStateFor(string module, string lookup) => GetStateFor(new List { module }, new List { lookup }); + + public ClipState? GetStateFor(IEnumerable modules, IEnumerable lookups) + { + try + { + return States.Single(clipState => clipState.ModuleNames.SequenceEqual(modules) && clipState.StateNames.SequenceEqual(lookups)); + } + catch (InvalidOperationException) + { + return null; + } + } + + public bool Evalulate() + { + if (!Enabled.Value) return false; + if (Start.Value > chatBoxManager.CurrentSecond || End.Value <= chatBoxManager.CurrentSecond) return false; + + if (currentEvent is not null) return true; + + var localStates = States.Select(state => state.Copy(true)).ToList(); + removeDisabledModules(localStates); + removeLessCompoundedStates(localStates); + removeInvalidStates(localStates); + + if (!localStates.Any()) return false; + + var chosenState = localStates.Single(); + if (!chosenState.Enabled.Value) return false; + + currentState = chosenState; + return true; + } + + private void removeDisabledModules(List localStates) + { + foreach (var clipState in localStates.ToImmutableList()) + { + var stateValid = clipState.ModuleNames.All(moduleName => chatBoxManager.ModuleEnabledCache[moduleName]); + if (!stateValid) localStates.Remove(clipState); + } + } + + private void removeLessCompoundedStates(List localStates) + { + var enabledAndAssociatedModules = AssociatedModules.Where(moduleName => chatBoxManager.ModuleEnabledCache[moduleName]).ToList(); + enabledAndAssociatedModules.Sort(); + + foreach (var clipState in localStates.ToImmutableList()) + { + var clipStateModules = clipState.ModuleNames; + clipStateModules.Sort(); + + if (!clipStateModules.SequenceEqual(enabledAndAssociatedModules)) localStates.Remove(clipState); + } + } + + private void removeInvalidStates(List localStates) + { + var currentStates = AssociatedModules.Where(moduleName => chatBoxManager.ModuleEnabledCache[moduleName] && chatBoxManager.StateValues.TryGetValue(moduleName, out _)).Select(moduleName => chatBoxManager.StateValues[moduleName]).ToList(); + currentStates.Sort(); + + if (!currentStates.Any()) return; + + foreach (var clipState in localStates.ToImmutableList()) + { + var clipStateStates = clipState.StateNames; + clipStateStates.Sort(); + + if (!clipStateStates.SequenceEqual(currentStates)) localStates.Remove(clipState); + } + } + + public string GetFormattedText() => currentEvent is not null ? formatText(currentEvent.Value.Item1) : formatText(currentState!); + + private string formatText(ClipState clipState) => formatText(clipState.Format.Value); + private string formatText(ClipEvent clipEvent) => formatText(clipEvent.Format.Value); + + private string formatText(string text) + { + var returnText = text; + + AvailableVariables.ForEach(clipVariable => + { + if (!chatBoxManager.ModuleEnabledCache[clipVariable.Module]) return; + + chatBoxManager.VariableValues.TryGetValue((clipVariable.Module, clipVariable.Lookup), out var variableValue); + + returnText = returnText.Replace(clipVariable.DisplayableFormat, variableValue ?? string.Empty); + }); + + return returnText; + } + + private void onAssociatedModulesChanged(NotifyCollectionChangedEventArgs e) + { + populateAvailableVariables(); + populateStates(e); + populateEvents(e); + } + + private void populateAvailableVariables() + { + AvailableVariables.Clear(); + + foreach (var module in AssociatedModules) + { + AvailableVariables.AddRange(chatBoxManager.VariableMetadata[module].Values); + } + } + + private void populateStates(NotifyCollectionChangedEventArgs e) + { + if (e.OldItems is not null) removeStatesOfRemovedModules(e); + if (e.NewItems is not null) addStatesOfAddedModules(e); + } + + private void removeStatesOfRemovedModules(NotifyCollectionChangedEventArgs e) + { + foreach (string oldModule in e.OldItems!) + { + States.RemoveAll(clipState => clipState.ModuleNames.Contains(oldModule)); + } + } + + private void addStatesOfAddedModules(NotifyCollectionChangedEventArgs e) + { + foreach (string moduleName in e.NewItems!) addStatesOfAddedModule(moduleName); + } + + private void addStatesOfAddedModule(string moduleName) + { + var currentStateCopy = States.Select(clipState => clipState.Copy()).ToList(); + var statesToAdd = chatBoxManager.StateMetadata[moduleName]; + + foreach (var (newStateName, newStateMetadata) in statesToAdd) + { + var localCurrentStatesCopy = currentStateCopy.Select(clipState => clipState.Copy()).ToList(); + + localCurrentStatesCopy.ForEach(newStateLocal => + { + newStateLocal.States.Add((moduleName, newStateName)); + newStateLocal.Enabled.BindValueChanged(_ => chatBoxManager.Save()); + newStateLocal.Format.BindValueChanged(_ => chatBoxManager.Save()); + }); + + States.AddRange(localCurrentStatesCopy); + var singleState = new ClipState(newStateMetadata); + singleState.Enabled.BindValueChanged(_ => chatBoxManager.Save()); + singleState.Format.BindValueChanged(_ => chatBoxManager.Save()); + States.Add(singleState); + } + } + + private void populateEvents(NotifyCollectionChangedEventArgs e) + { + if (e.OldItems is not null) removeEventsOfRemovedModules(e); + if (e.NewItems is not null) addEventsOfAddedModules(e); + } + + private void removeEventsOfRemovedModules(NotifyCollectionChangedEventArgs e) + { + foreach (string oldModule in e.OldItems!) + { + Events.RemoveAll(clipEvent => clipEvent.Module == oldModule); + } + } + + private void addEventsOfAddedModules(NotifyCollectionChangedEventArgs e) + { + foreach (string moduleName in e.NewItems!) + { + if (!chatBoxManager.EventMetadata.TryGetValue(moduleName, out var events)) continue; + + foreach (var (_, metadata) in events) + { + var newEvent = new ClipEvent(metadata); + newEvent.Enabled.BindValueChanged(_ => chatBoxManager.Save()); + newEvent.Format.BindValueChanged(_ => chatBoxManager.Save()); + newEvent.Length.BindValueChanged(_ => chatBoxManager.Save()); + Events.Add(newEvent); + } + } + } + + public bool Intersects(Clip other) + { + if (Start.Value >= other.Start.Value && Start.Value < other.End.Value) return true; + if (End.Value <= other.End.Value && End.Value > other.Start.Value) return true; + if (Start.Value < other.Start.Value && End.Value > other.End.Value) return true; + + return false; + } +} diff --git a/VRCOSC.Game/ChatBox/Clips/ClipEvent.cs b/VRCOSC.Game/ChatBox/Clips/ClipEvent.cs new file mode 100644 index 00000000..fd3933ef --- /dev/null +++ b/VRCOSC.Game/ChatBox/Clips/ClipEvent.cs @@ -0,0 +1,44 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Bindables; + +namespace VRCOSC.Game.ChatBox.Clips; + +public class ClipEvent +{ + public readonly string Module; + public readonly string Lookup; + public readonly string Name; + + public Bindable Format = new() + { + Default = string.Empty, + Value = string.Empty + }; + + public Bindable Enabled = new(); + public Bindable Length = new(); + + public bool IsDefault => Format.IsDefault && Enabled.IsDefault && Length.IsDefault; + + public ClipEvent(ClipEventMetadata metadata) + { + Module = metadata.Module; + Lookup = metadata.Lookup; + Name = metadata.Name; + Format.Value = metadata.DefaultFormat; + Format.Default = metadata.DefaultFormat; + Length.Value = metadata.DefaultLength; + Length.Default = metadata.DefaultLength; + } +} + +public class ClipEventMetadata +{ + public required string Module { get; init; } + public required string Lookup { get; init; } + public required string Name { get; init; } + public required string DefaultFormat { get; init; } + public required int DefaultLength { get; init; } +} diff --git a/VRCOSC.Game/ChatBox/Clips/ClipState.cs b/VRCOSC.Game/ChatBox/Clips/ClipState.cs new file mode 100644 index 00000000..2d3ecec1 --- /dev/null +++ b/VRCOSC.Game/ChatBox/Clips/ClipState.cs @@ -0,0 +1,62 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; + +namespace VRCOSC.Game.ChatBox.Clips; + +public class ClipState +{ + public List<(string, string)> States { get; private init; } = null!; + + public Bindable Format = new() + { + Default = string.Empty, + Value = string.Empty + }; + + public Bindable Enabled = new(); + + public List ModuleNames => States.Select(state => state.Item1).ToList(); + public List StateNames => States.Select(state => state.Item2).ToList(); + + public bool IsDefault => Format.IsDefault && Enabled.IsDefault; + + public ClipState Copy(bool includeData = false) + { + var statesCopy = new List<(string, string)>(); + States.ForEach(state => statesCopy.Add(state)); + + var copy = new ClipState + { + States = statesCopy + }; + + if (includeData) + { + copy.Format = Format.GetUnboundCopy(); + copy.Enabled = Enabled.GetUnboundCopy(); + } + + return copy; + } + + private ClipState() { } + + public ClipState(ClipStateMetadata metadata) + { + States = new List<(string, string)> { new(metadata.Module, metadata.Lookup) }; + Format.Value = metadata.DefaultFormat; + Format.Default = metadata.DefaultFormat; + } +} + +public class ClipStateMetadata +{ + public required string Module { get; init; } + public required string Lookup { get; init; } + public required string Name { get; init; } + public required string DefaultFormat { get; init; } +} diff --git a/VRCOSC.Game/ChatBox/Clips/ClipVariable.cs b/VRCOSC.Game/ChatBox/Clips/ClipVariable.cs new file mode 100644 index 00000000..f0e6f71e --- /dev/null +++ b/VRCOSC.Game/ChatBox/Clips/ClipVariable.cs @@ -0,0 +1,20 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +namespace VRCOSC.Game.ChatBox.Clips; + +/// +/// Used by modules to denote a provided variable +/// +public class ClipVariableMetadata +{ + private const string variable_start_char = "{"; + private const string variable_end_char = "}"; + + public required string Module { get; init; } + public required string Lookup { get; init; } + public required string Name { get; init; } + public required string Format { get; init; } + + public string DisplayableFormat => $"{variable_start_char}{Module.Replace("module", string.Empty)}.{Format}{variable_end_char}"; +} diff --git a/VRCOSC.Game/ChatBox/DefaultTimeline.cs b/VRCOSC.Game/ChatBox/DefaultTimeline.cs new file mode 100644 index 00000000..840d748a --- /dev/null +++ b/VRCOSC.Game/ChatBox/DefaultTimeline.cs @@ -0,0 +1,100 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using VRCOSC.Game.ChatBox.Clips; + +namespace VRCOSC.Game.ChatBox; + +public static class DefaultTimeline +{ + public static IEnumerable GenerateDefaultTimeline(ChatBoxManager chatBoxManager) + { + yield return generateClockClip(chatBoxManager); + yield return generateHwsClip(chatBoxManager); + yield return generateWeatherClip(chatBoxManager); + yield return generateHeartrateClip(chatBoxManager); + yield return generateChatBoxTextClip(chatBoxManager); + yield return generateMediaClip(chatBoxManager); + } + + private static Clip generateClockClip(ChatBoxManager chatBoxManager) + { + var clip = chatBoxManager.CreateClip(); + clip.Name.Value = @"Clock"; + clip.Priority.Value = 0; + clip.Start.Value = 0; + clip.End.Value = 60; + clip.AssociatedModules.Add(@"clockmodule"); + clip.GetStateFor(@"clockmodule", @"default")!.Enabled.Value = true; + + return clip; + } + + private static Clip generateHwsClip(ChatBoxManager chatBoxManager) + { + var clip = chatBoxManager.CreateClip(); + clip.Name.Value = @"Hardware Stats"; + clip.Priority.Value = 1; + clip.Start.Value = 0; + clip.End.Value = 60; + clip.AssociatedModules.Add(@"hardwarestatsmodule"); + clip.GetStateFor(@"hardwarestatsmodule", @"default")!.Enabled.Value = true; + + return clip; + } + + private static Clip generateWeatherClip(ChatBoxManager chatBoxManager) + { + var clip = chatBoxManager.CreateClip(); + clip.Name.Value = @"Weather"; + clip.Priority.Value = 2; + clip.Start.Value = 0; + clip.End.Value = 60; + clip.AssociatedModules.Add(@"weathermodule"); + clip.GetStateFor(@"weathermodule", @"default")!.Enabled.Value = true; + + return clip; + } + + private static Clip generateHeartrateClip(ChatBoxManager chatBoxManager) + { + var clip = chatBoxManager.CreateClip(); + clip.Name.Value = @"Heartrate"; + clip.Priority.Value = 3; + clip.Start.Value = 0; + clip.End.Value = 60; + clip.AssociatedModules.Add(@"hyperatemodule"); + clip.AssociatedModules.Add(@"pulsoidmodule"); + clip.GetStateFor(@"hyperatemodule", @"default")!.Enabled.Value = true; + clip.GetStateFor(@"pulsoidmodule", @"default")!.Enabled.Value = true; + + return clip; + } + + private static Clip generateChatBoxTextClip(ChatBoxManager chatBoxManager) + { + var clip = chatBoxManager.CreateClip(); + clip.Name.Value = @"ChatBox Text"; + clip.Priority.Value = 4; + clip.Start.Value = 0; + clip.End.Value = 60; + clip.AssociatedModules.Add(@"chatboxtextmodule"); + clip.GetStateFor(@"chatboxtextmodule", @"default")!.Enabled.Value = true; + + return clip; + } + + private static Clip generateMediaClip(ChatBoxManager chatBoxManager) + { + var clip = chatBoxManager.CreateClip(); + clip.Name.Value = @"Media"; + clip.Priority.Value = 5; + clip.Start.Value = 0; + clip.End.Value = 60; + clip.AssociatedModules.Add(@"mediamodule"); + clip.GetStateFor(@"mediamodule", @"playing")!.Enabled.Value = true; + + return clip; + } +} diff --git a/VRCOSC.Game/ChatBox/Serialisation/ITimelineSerialiser.cs b/VRCOSC.Game/ChatBox/Serialisation/ITimelineSerialiser.cs new file mode 100644 index 00000000..09e5da14 --- /dev/null +++ b/VRCOSC.Game/ChatBox/Serialisation/ITimelineSerialiser.cs @@ -0,0 +1,14 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.ChatBox.Serialisation.V1.Structures; + +namespace VRCOSC.Game.ChatBox.Serialisation; + +public interface ITimelineSerialiser +{ + public void Serialise(List clips); + public SerialisableTimeline? Deserialise(); +} diff --git a/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableClip.cs b/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableClip.cs new file mode 100644 index 00000000..f8461790 --- /dev/null +++ b/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableClip.cs @@ -0,0 +1,62 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using osu.Framework.Extensions.IEnumerableExtensions; +using VRCOSC.Game.ChatBox.Clips; + +namespace VRCOSC.Game.ChatBox.Serialisation.V1.Structures; + +public class SerialisableClip +{ + [JsonProperty("enabled")] + public bool Enabled; + + [JsonProperty("name")] + public string Name = null!; + + [JsonProperty("priority")] + public int Priority; + + [JsonProperty("associated_modules")] + public List AssociatedModules = new(); + + [JsonProperty("start")] + public int Start; + + [JsonProperty("end")] + public int End; + + [JsonProperty("states")] + public List States = new(); + + [JsonProperty("events")] + public List Events = new(); + + [JsonConstructor] + public SerialisableClip() + { + } + + public SerialisableClip(Clip clip) + { + Enabled = clip.Enabled.Value; + Name = clip.Name.Value; + Priority = clip.Priority.Value; + AssociatedModules = clip.AssociatedModules.ToList(); + Start = clip.Start.Value; + End = clip.End.Value; + + clip.States.ForEach(clipState => + { + if (!clipState.IsDefault) States.Add(new SerialisableClipState(clipState)); + }); + + clip.Events.ForEach(clipEvent => + { + if (!clipEvent.IsDefault) Events.Add(new SerialisableClipEvent(clipEvent)); + }); + } +} diff --git a/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableClipEvent.cs b/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableClipEvent.cs new file mode 100644 index 00000000..86df31ea --- /dev/null +++ b/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableClipEvent.cs @@ -0,0 +1,39 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using Newtonsoft.Json; +using VRCOSC.Game.ChatBox.Clips; + +namespace VRCOSC.Game.ChatBox.Serialisation.V1.Structures; + +public class SerialisableClipEvent +{ + [JsonProperty("module")] + public string Module = null!; + + [JsonProperty("lookup")] + public string Lookup = null!; + + [JsonProperty("format")] + public string Format = null!; + + [JsonProperty("enabled")] + public bool Enabled; + + [JsonProperty("length")] + public int Length; + + [JsonConstructor] + public SerialisableClipEvent() + { + } + + public SerialisableClipEvent(ClipEvent clipEvent) + { + Module = clipEvent.Module; + Lookup = clipEvent.Lookup; + Format = clipEvent.Format.Value; + Enabled = clipEvent.Enabled.Value; + Length = clipEvent.Length.Value; + } +} diff --git a/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableClipState.cs b/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableClipState.cs new file mode 100644 index 00000000..4153c172 --- /dev/null +++ b/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableClipState.cs @@ -0,0 +1,52 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using VRCOSC.Game.ChatBox.Clips; + +namespace VRCOSC.Game.ChatBox.Serialisation.V1.Structures; + +public class SerialisableClipState +{ + [JsonProperty("states")] + public List States = new(); + + [JsonProperty("format")] + public string Format = null!; + + [JsonProperty("enabled")] + public bool Enabled; + + [JsonConstructor] + public SerialisableClipState() + { + } + + public SerialisableClipState(ClipState clipState) + { + clipState.States.ForEach(pair => States.Add(new SerialisableClipStateStates(pair))); + Format = clipState.Format.Value; + Enabled = clipState.Enabled.Value; + } +} + +public class SerialisableClipStateStates +{ + [JsonProperty("module")] + public string Module = null!; + + [JsonProperty("lookup")] + public string Lookup = null!; + + [JsonConstructor] + public SerialisableClipStateStates() + { + } + + public SerialisableClipStateStates((string, string) pair) + { + Module = pair.Item1; + Lookup = pair.Item2; + } +} diff --git a/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableTimeline.cs b/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableTimeline.cs new file mode 100644 index 00000000..a05349dd --- /dev/null +++ b/VRCOSC.Game/ChatBox/Serialisation/V1/Structures/SerialisableTimeline.cs @@ -0,0 +1,27 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using VRCOSC.Game.ChatBox.Clips; + +namespace VRCOSC.Game.ChatBox.Serialisation.V1.Structures; + +public class SerialisableTimeline +{ + [JsonProperty("version")] + public int Version = 1; + + [JsonProperty("clips")] + public List Clips = new(); + + [JsonConstructor] + public SerialisableTimeline() + { + } + + public SerialisableTimeline(List clips) + { + clips.ForEach(clip => Clips.Add(new SerialisableClip(clip))); + } +} diff --git a/VRCOSC.Game/ChatBox/Serialisation/V1/TimelineSerialiser.cs b/VRCOSC.Game/ChatBox/Serialisation/V1/TimelineSerialiser.cs new file mode 100644 index 00000000..6af30c42 --- /dev/null +++ b/VRCOSC.Game/ChatBox/Serialisation/V1/TimelineSerialiser.cs @@ -0,0 +1,44 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using osu.Framework.Platform; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.ChatBox.Serialisation.V1.Structures; + +namespace VRCOSC.Game.ChatBox.Serialisation.V1; + +public class TimelineSerialiser : ITimelineSerialiser +{ + private const string file_name = @"chatbox.json"; + private readonly Storage storage; + + public TimelineSerialiser(Storage storage) + { + this.storage = storage; + } + + public void Serialise(List clips) + { + using var stream = storage.CreateFileSafely(file_name); + using var writer = new StreamWriter(stream); + writer.Write(JsonConvert.SerializeObject(new SerialisableTimeline(clips))); + } + + public SerialisableTimeline? Deserialise() + { + using (var stream = storage.GetStream(file_name)) + { + if (stream is not null) + { + using var reader = new StreamReader(stream); + + return JsonConvert.DeserializeObject(reader.ReadToEnd()); + } + } + + return null; + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/ChatBoxScreen.cs b/VRCOSC.Game/Graphics/ChatBox/ChatBoxScreen.cs new file mode 100644 index 00000000..909d3e78 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/ChatBoxScreen.cs @@ -0,0 +1,78 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using VRCOSC.Game.Graphics.ChatBox.SelectedClip; +using VRCOSC.Game.Graphics.ChatBox.Timeline; +using VRCOSC.Game.Graphics.ChatBox.Timeline.Menu.Clip; +using VRCOSC.Game.Graphics.ChatBox.Timeline.Menu.Layer; +using VRCOSC.Game.Graphics.Themes; + +namespace VRCOSC.Game.Graphics.ChatBox; + +[Cached] +public partial class ChatBoxScreen : Container +{ + [Cached] + private TimelineLayerMenu layerMenu = new(); + + [Cached] + private TimelineClipMenu clipMenu = new(); + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ThemeManager.Current[ThemeAttribute.Light] + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new SelectedClipEditorWrapper + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both + }, + }, + null, + new Drawable[] + { + new TimelineWrapper + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both + } + } + } + } + }, + layerMenu, + clipMenu + }; + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Metadata/MetadataString.cs b/VRCOSC.Game/Graphics/ChatBox/Metadata/MetadataString.cs new file mode 100644 index 00000000..8a3832ad --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Metadata/MetadataString.cs @@ -0,0 +1,100 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using VRCOSC.Game.ChatBox; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Graphics.UI.Text; + +namespace VRCOSC.Game.Graphics.ChatBox.Metadata; + +public partial class MetadataString : Container +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + public required string Label { get; init; } + public required Bindable Current { get; init; } + + private LocalTextBox inputTextBox = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Light], + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(3), + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Padding = new MarginPadding(2), + Child = new SpriteText + { + Font = FrameworkFont.Regular.With(size: 22), + Text = Label, + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + }, + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Children = new Drawable[] + { + inputTextBox = new LocalTextBox + { + RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + MinimumLength = 2, + EmptyIsValid = false + } + } + } + } + } + }; + } + + protected override void LoadComplete() + { + inputTextBox.Text = Current.Value; + inputTextBox.OnValidEntry += value => Current.Value = value; + } + + private partial class LocalTextBox : StringTextBox + { + [BackgroundDependencyLoader] + private void load() + { + BackgroundUnfocused = ThemeManager.Current[ThemeAttribute.Dark]; + BackgroundFocused = ThemeManager.Current[ThemeAttribute.Dark]; + } + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Metadata/MetadataToggle.cs b/VRCOSC.Game/Graphics/ChatBox/Metadata/MetadataToggle.cs new file mode 100644 index 00000000..20645117 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Metadata/MetadataToggle.cs @@ -0,0 +1,80 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Graphics.UI.Button; + +namespace VRCOSC.Game.Graphics.ChatBox.Metadata; + +public partial class MetadataToggle : Container +{ + public required string Label { get; init; } + public required Bindable State { get; init; } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Light], + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(3), + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Padding = new MarginPadding(2), + Child = new SpriteText + { + Font = FrameworkFont.Regular.With(size: 22), + Text = Label, + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + }, + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Masking = true, + CornerRadius = 5, + Children = new Drawable[] + { + new ToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + State = State + } + } + } + } + } + }; + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Metadata/ReadonlyTimeDisplay.cs b/VRCOSC.Game/Graphics/ChatBox/Metadata/ReadonlyTimeDisplay.cs new file mode 100644 index 00000000..697b1ec8 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Metadata/ReadonlyTimeDisplay.cs @@ -0,0 +1,106 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Globalization; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using VRCOSC.Game.ChatBox; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Graphics.UI; + +namespace VRCOSC.Game.Graphics.ChatBox.Metadata; + +public partial class ReadonlyTimeDisplay : Container +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + public required string Label { get; init; } + public required Bindable Current { get; init; } + + private VRCOSCTextBox textBox = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Light], + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(3), + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Padding = new MarginPadding(2), + Child = new SpriteText + { + Font = FrameworkFont.Regular.With(size: 22), + Text = Label, + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + }, + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Children = new Drawable[] + { + textBox = new LocalTextBox + { + RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + ReadOnly = true + } + } + } + } + } + }; + } + + protected override void LoadComplete() + { + chatBoxManager.TimelineLength.BindValueChanged(_ => updateText()); + Current.BindValueChanged(_ => updateText()); + updateText(); + } + + private void updateText() + { + textBox.Text = Current.Value.ToString("##0", CultureInfo.InvariantCulture); + } + + private partial class LocalTextBox : VRCOSCTextBox + { + [BackgroundDependencyLoader] + private void load() + { + BackgroundUnfocused = ThemeManager.Current[ThemeAttribute.Mid]; + BackgroundFocused = ThemeManager.Current[ThemeAttribute.Mid]; + } + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableAssociatedModule.cs b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableAssociatedModule.cs new file mode 100644 index 00000000..64772efe --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableAssociatedModule.cs @@ -0,0 +1,80 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Graphics.UI.Button; + +namespace VRCOSC.Game.Graphics.ChatBox.SelectedClip; + +public partial class DrawableAssociatedModule : Container +{ + public required string ModuleName { get; init; } + public readonly Bindable State = new(); + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Light], + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(3), + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Padding = new MarginPadding(2), + Child = new SpriteText + { + Font = FrameworkFont.Regular.With(size: 22), + Text = ModuleName, + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + }, + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Masking = true, + CornerRadius = 5, + Children = new Drawable[] + { + new ToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + State = State.GetBoundCopy() + } + } + } + } + } + }; + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableEvent.cs b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableEvent.cs new file mode 100644 index 00000000..823e327b --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableEvent.cs @@ -0,0 +1,140 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osuTK; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Graphics.UI; +using VRCOSC.Game.Graphics.UI.Button; +using VRCOSC.Game.Graphics.UI.Text; +using VRCOSC.Game.Modules; + +namespace VRCOSC.Game.Graphics.ChatBox.SelectedClip; + +public partial class DrawableEvent : Container +{ + [Resolved] + private GameManager gameManager { get; set; } = null!; + + private readonly ClipEvent clipEvent; + + public DrawableEvent(ClipEvent clipEvent) + { + this.clipEvent = clipEvent; + } + + [BackgroundDependencyLoader] + private void load() + { + IntTextBox lengthTextBox; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Light], + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(3), + Spacing = new Vector2(0, 2), + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = 30, + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Child = new ToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + State = clipEvent.Enabled.GetBoundCopy() + } + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(3), + Child = new SpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = FrameworkFont.Regular.With(size: 20), + Text = gameManager.ModuleManager.GetModuleName(clipEvent.Module) + " - " + clipEvent.Name + ":", + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + } + } + }, + new GridContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = 30, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 3), + new Dimension(GridSizeMode.Relative, 0.1f) + }, + Content = new[] + { + new Drawable?[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Child = new VRCOSCTextBox + { + RelativeSizeAxes = Axes.Both, + Current = clipEvent.Format.GetBoundCopy(), + Masking = true, + CornerRadius = 5 + } + }, + null, + new Container + { + RelativeSizeAxes = Axes.Both, + Child = lengthTextBox = new IntTextBox + { + RelativeSizeAxes = Axes.Both, + Text = clipEvent.Length.Value.ToString(), + Masking = true, + CornerRadius = 5, + PlaceholderText = "Length" + } + }, + } + } + } + } + } + }; + + lengthTextBox.OnValidEntry = newLength => clipEvent.Length.Value = newLength; + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableModuleVariables.cs b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableModuleVariables.cs new file mode 100644 index 00000000..ca30b658 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableModuleVariables.cs @@ -0,0 +1,57 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osuTK; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Modules; + +namespace VRCOSC.Game.Graphics.ChatBox.SelectedClip; + +public partial class DrawableModuleVariables : Container +{ + [Resolved] + private GameManager gameManager { get; set; } = null!; + + private readonly string module; + private readonly List clipVariables; + + public DrawableModuleVariables(string module, List clipVariables) + { + this.module = module; + this.clipVariables = clipVariables; + } + + [BackgroundDependencyLoader] + private void load() + { + FillFlowContainer variableFlow; + + Child = variableFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5) + }; + + variableFlow.Add(new SpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = gameManager.ModuleManager.GetModuleName(module), + Font = FrameworkFont.Regular.With(size: 20), + Colour = ThemeManager.Current[ThemeAttribute.Text] + }); + + clipVariables.ForEach(clipVariable => + { + variableFlow.Add(new DrawableVariable(clipVariable)); + }); + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableState.cs b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableState.cs new file mode 100644 index 00000000..043cb81c --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableState.cs @@ -0,0 +1,124 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osuTK; +using VRCOSC.Game.ChatBox; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Graphics.UI; +using VRCOSC.Game.Graphics.UI.Button; +using VRCOSC.Game.Modules; + +namespace VRCOSC.Game.Graphics.ChatBox.SelectedClip; + +public partial class DrawableState : Container +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + [Resolved] + private GameManager gameManager { get; set; } = null!; + + public readonly ClipState ClipState; + + public DrawableState(ClipState clipState) + { + ClipState = clipState; + } + + [BackgroundDependencyLoader] + private void load() + { + var stateNameList = string.Empty; + + ClipState.States.ForEach(pair => + { + var stateMetadata = chatBoxManager.StateMetadata[pair.Item1][pair.Item2]; + stateNameList += gameManager.ModuleManager.GetModuleName(pair.Item1); + if (stateMetadata.Name != "Default") stateNameList += " - " + stateMetadata.Name; + stateNameList += " & "; + }); + + stateNameList = stateNameList.TrimEnd(' ', '&'); + stateNameList += ":"; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Light], + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(3), + Spacing = new Vector2(0, 2), + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + Height = 30, + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Child = new ToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + State = ClipState.Enabled.GetBoundCopy() + } + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(3), + Child = new SpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = FrameworkFont.Regular.With(size: 20), + Text = stateNameList, + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + } + } + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new VRCOSCTextBox + { + RelativeSizeAxes = Axes.X, + Height = 30, + Current = ClipState.Format.GetBoundCopy(), + Masking = true, + CornerRadius = 5 + } + } + } + } + }; + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableVariable.cs b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableVariable.cs new file mode 100644 index 00000000..d09979aa --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/DrawableVariable.cs @@ -0,0 +1,88 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Graphics.UI; + +namespace VRCOSC.Game.Graphics.ChatBox.SelectedClip; + +public partial class DrawableVariable : Container +{ + private readonly ClipVariableMetadata clipVariable; + + public DrawableVariable(ClipVariableMetadata clipVariable) + { + this.clipVariable = clipVariable; + } + + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Light], + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(3), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Padding = new MarginPadding(2), + Child = new SpriteText + { + Font = FrameworkFont.Regular.With(size: 20), + Text = clipVariable.Name + ":", + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + }, + new LocalTextBox + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + RelativeSizeAxes = Axes.X, + Height = 25, + CornerRadius = 5, + Text = clipVariable.DisplayableFormat, + ReadOnly = true + } + } + } + }; + } + + private partial class LocalTextBox : VRCOSCTextBox + { + [BackgroundDependencyLoader] + private void load() + { + BackgroundUnfocused = ThemeManager.Current[ThemeAttribute.Mid]; + BackgroundFocused = ThemeManager.Current[ThemeAttribute.Mid]; + } + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipEditorWrapper.cs b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipEditorWrapper.cs new file mode 100644 index 00000000..6108b0f3 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipEditorWrapper.cs @@ -0,0 +1,112 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using VRCOSC.Game.ChatBox; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.Graphics.Themes; + +namespace VRCOSC.Game.Graphics.ChatBox.SelectedClip; + +public partial class SelectedClipEditorWrapper : Container +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + private Container noClipContent = null!; + private GridContainer gridContent = null!; + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Dark], + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(5), + Children = new Drawable[] + { + noClipContent = new Container + { + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = FrameworkFont.Regular.With(size: 40), + Text = "Select a clip to edit", + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + } + }, + gridContent = new GridContainer + { + Alpha = 0, + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.15f), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(GridSizeMode.Relative, 0.15f), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(GridSizeMode.Relative, 0.15f), + }, + Content = new[] + { + new Drawable?[] + { + new SelectedClipMetadataEditor(), + null, + new SelectedClipModuleSelector(), + null, + new SelectedClipStateEditorContainer(), + null, + new SelectedClipVariableContainer() + } + } + } + } + } + } + } + }; + + chatBoxManager.SelectedClip.BindValueChanged(e => selectBestVisual(e.NewValue), true); + } + + private void selectBestVisual(Clip? clip) + { + if (clip is null) + { + gridContent.FadeOut(250, Easing.OutQuad); + noClipContent.FadeIn(250, Easing.InQuad); + } + else + { + noClipContent.FadeOut(250, Easing.OutQuad); + gridContent.FadeIn(250, Easing.InQuad); + } + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipMetadataEditor.cs b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipMetadataEditor.cs new file mode 100644 index 00000000..a3fbbd46 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipMetadataEditor.cs @@ -0,0 +1,137 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osuTK; +using VRCOSC.Game.ChatBox; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.Graphics.ChatBox.Metadata; +using VRCOSC.Game.Graphics.Themes; + +namespace VRCOSC.Game.Graphics.ChatBox.SelectedClip; + +public partial class SelectedClipMetadataEditor : Container +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + private FillFlowContainer metadataFlow = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 10; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Darker], + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.05f), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Clip Settings", + Font = FrameworkFont.Regular.With(size: 30), + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + }, + null, + new Drawable[] + { + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + ClampExtension = 5, + Child = metadataFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5) + } + } + } + } + } + } + }; + } + + protected override void LoadComplete() + { + chatBoxManager.SelectedClip.BindValueChanged(e => onSelectedClipChange(e.NewValue), true); + } + + private void onSelectedClipChange(Clip? clip) + { + if (clip is null) return; + + metadataFlow.Clear(); + + metadataFlow.Add(new MetadataToggle + { + Label = "Enabled", + State = clip.Enabled.GetBoundCopy() + }); + + metadataFlow.Add(new MetadataString + { + Label = "Name", + Current = clip.Name.GetBoundCopy() + }); + + metadataFlow.Add(new SpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Metadata", + Font = FrameworkFont.Regular.With(size: 25), + Colour = ThemeManager.Current[ThemeAttribute.Text] + }); + + metadataFlow.Add(new ReadonlyTimeDisplay + { + Label = "Start", + Current = clip.Start.GetBoundCopy() + }); + + metadataFlow.Add(new ReadonlyTimeDisplay + { + Label = "End", + Current = clip.End.GetBoundCopy() + }); + + metadataFlow.Add(new ReadonlyTimeDisplay + { + Label = "Priority", + Current = clip.Priority.GetBoundCopy() + }); + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipModuleSelector.cs b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipModuleSelector.cs new file mode 100644 index 00000000..3d14cfe4 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipModuleSelector.cs @@ -0,0 +1,125 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osuTK; +using VRCOSC.Game.ChatBox; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Modules; +using VRCOSC.Game.Modules.ChatBox; + +namespace VRCOSC.Game.Graphics.ChatBox.SelectedClip; + +public partial class SelectedClipModuleSelector : Container +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + [Resolved] + private GameManager gameManager { get; set; } = null!; + + private FillFlowContainer moduleFlow = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 10; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Darker], + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.05f), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Select Modules", + Font = FrameworkFont.Regular.With(size: 30), + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + }, + null, + new Drawable[] + { + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + ClampExtension = 5, + ScrollbarVisible = false, + Child = moduleFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5) + } + } + } + } + } + } + }; + + chatBoxManager.SelectedClip.BindValueChanged(e => + { + if (e.NewValue is null) return; + + var newClip = e.NewValue; + + moduleFlow.Clear(); + + foreach (var module in gameManager.ModuleManager.Where(module => module.GetType().IsSubclassOf(typeof(ChatBoxModule)))) + { + DrawableAssociatedModule drawableAssociatedModule; + + moduleFlow.Add(drawableAssociatedModule = new DrawableAssociatedModule + { + ModuleName = module.Title + }); + + foreach (string moduleName in newClip.AssociatedModules) + { + if (module.SerialisedName == moduleName) + { + drawableAssociatedModule.State.Value = true; + } + } + + drawableAssociatedModule.State.BindValueChanged(e => + { + if (e.NewValue) + newClip.AssociatedModules.Add(module.SerialisedName); + else + newClip.AssociatedModules.Remove(module.SerialisedName); + }); + } + }, true); + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipStateEditorContainer.cs b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipStateEditorContainer.cs new file mode 100644 index 00000000..6d5fd04c --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipStateEditorContainer.cs @@ -0,0 +1,278 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osuTK; +using VRCOSC.Game.ChatBox; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Graphics.UI.Button; +using VRCOSC.Game.Modules.Manager; + +namespace VRCOSC.Game.Graphics.ChatBox.SelectedClip; + +public partial class SelectedClipStateEditorContainer : Container +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + private Container statesTitle = null!; + private FillFlowContainer stateFlow = null!; + private LineSeparator separator = null!; + private Container eventsTitle = null!; + private FillFlowContainer eventFlow = null!; + + private Bindable showRelevantStates = new(true); + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 10; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Darker], + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = 10, + Top = 10, + Bottom = 50 + }, + Child = new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + ClampExtension = 5, + ScrollbarVisible = false, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + statesTitle = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "States", + Font = FrameworkFont.Regular.With(size: 30), + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + }, + stateFlow = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5) + } + } + }, + separator = new LineSeparator + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + LineColour = ThemeManager.Current[ThemeAttribute.Mid] + }, + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + eventsTitle = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Events", + Font = FrameworkFont.Regular.With(size: 30), + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + }, + eventFlow = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5) + } + } + } + } + } + } + }, + new Container + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + Height = 50, + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Padding = new MarginPadding(5), + Children = new Drawable[] + { + new ToggleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + State = showRelevantStates.GetBoundCopy() + } + } + }, + new SpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = "Show relevant states only (Based on enabled modules)", + Colour = ThemeManager.Current[ThemeAttribute.SubText] + } + } + } + } + } + }; + } + + protected override void LoadComplete() + { + chatBoxManager.SelectedClip.BindValueChanged(e => + { + if (e.OldValue is not null) e.OldValue.AssociatedModules.CollectionChanged -= associatedModulesOnCollectionChanged; + + if (e.NewValue is not null) + { + e.NewValue.AssociatedModules.CollectionChanged += associatedModulesOnCollectionChanged; + associatedModulesOnCollectionChanged(null, null); + } + }, true); + + ((ModuleManager)chatBoxManager.GameManager.ModuleManager).OnModuleEnabledChanged += filterFlows; + showRelevantStates.BindValueChanged(_ => filterFlows(), true); + } + + private void filterFlows() + { + if (showRelevantStates.Value) + { + var enabledModuleNames = chatBoxManager.GameManager.ModuleManager.GetEnabledModuleNames().Where(moduleName => chatBoxManager.SelectedClip.Value!.AssociatedModules.Contains(moduleName)).ToList(); + enabledModuleNames.Sort(); + + stateFlow.ForEach(drawableState => + { + var sortedClipModuleNames = drawableState.ClipState.ModuleNames; + sortedClipModuleNames.Sort(); + + drawableState.Alpha = enabledModuleNames.SequenceEqual(sortedClipModuleNames) ? 1 : 0; + }); + } + else + { + stateFlow.ForEach(drawableState => drawableState.Alpha = 1); + } + } + + private void associatedModulesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs? e) + { + stateFlow.Clear(); + eventFlow.Clear(); + + // TODO - Don't regenerate whole + + chatBoxManager.SelectedClip.Value?.States.ForEach(clipState => + { + DrawableState drawableState; + + stateFlow.Add(drawableState = new DrawableState(clipState) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 5, + }); + stateFlow.SetLayoutPosition(drawableState, clipState.States.Count); + }); + + chatBoxManager.SelectedClip.Value?.Events.ForEach(clipEvent => + { + eventFlow.Add(new DrawableEvent(clipEvent) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 5 + }); + }); + + statesTitle.Alpha = stateFlow.Children.Count == 0 ? 0 : 1; + eventsTitle.Alpha = separator.Alpha = eventFlow.Children.Count == 0 ? 0 : 1; + + filterFlows(); + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipVariableContainer.cs b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipVariableContainer.cs new file mode 100644 index 00000000..f0fa8f36 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/SelectedClip/SelectedClipVariableContainer.cs @@ -0,0 +1,130 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using System.Collections.Specialized; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osuTK; +using VRCOSC.Game.ChatBox; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.Graphics.Themes; + +namespace VRCOSC.Game.Graphics.ChatBox.SelectedClip; + +public partial class SelectedClipVariableContainer : Container +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + private FillFlowContainer moduleVariableFlow = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 10; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Darker], + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.05f), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Available Variables", + Font = FrameworkFont.Regular.With(size: 30), + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + }, + null, + new Drawable[] + { + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + ClampExtension = 5, + Child = moduleVariableFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10) + } + } + } + } + } + } + }; + } + + protected override void LoadComplete() + { + chatBoxManager.SelectedClip.BindValueChanged(e => + { + if (e.OldValue is not null) e.OldValue.AvailableVariables.CollectionChanged -= availableVariablesOnCollectionChanged; + + if (e.NewValue is not null) + { + e.NewValue.AvailableVariables.CollectionChanged += availableVariablesOnCollectionChanged; + availableVariablesOnCollectionChanged(null, null); + } + }, true); + } + + private void availableVariablesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs? e) + { + moduleVariableFlow.Clear(); + + // TODO Don't regenerate whole + + var groupedVariables = new Dictionary>(); + + chatBoxManager.SelectedClip.Value?.AvailableVariables.ForEach(clipVariable => + { + if (!groupedVariables.ContainsKey(clipVariable.Module)) groupedVariables.Add(clipVariable.Module, new List()); + groupedVariables[clipVariable.Module].Add(clipVariable); + }); + + groupedVariables.ForEach(pair => + { + var (moduleName, clipVariables) = pair; + + moduleVariableFlow.Add(new DrawableModuleVariables(moduleName, clipVariables) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }); + }); + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Timeline/DrawableClip.cs b/VRCOSC.Game/Graphics/ChatBox/Timeline/DrawableClip.cs new file mode 100644 index 00000000..33b05321 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Timeline/DrawableClip.cs @@ -0,0 +1,280 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osuTK.Graphics; +using osuTK.Input; +using VRCOSC.Game.ChatBox; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.Graphics.Themes; + +namespace VRCOSC.Game.Graphics.ChatBox.Timeline; + +[Cached] +public partial class DrawableClip : Container +{ + [Resolved] + private TimelineEditor timelineEditor { get; set; } = null!; + + [Resolved] + private TimelineLayer timelineLayer { get; set; } = null!; + + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + public readonly Clip Clip; + + private float cumulativeDrag; + private SpriteText drawName = null!; + + public DrawableClip(Clip clip) + { + Clip = clip; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + RelativePositionAxes = Axes.X; + + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + BorderColour = ThemeManager.Current[ThemeAttribute.Accent], + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Light], + RelativeSizeAxes = Axes.Both + }, + new StartResizeDetector(Clip, v => v / Parent.DrawWidth) + { + RelativeSizeAxes = Axes.Y, + Width = 15 + }, + new EndResizeDetector(Clip, v => v / Parent.DrawWidth) + { + RelativeSizeAxes = Axes.Y, + Width = 15 + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Children = new Drawable[] + { + drawName = new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = FrameworkFont.Regular.With(size: 20), + Colour = ThemeManager.Current[ThemeAttribute.Text] + } + } + } + } + }; + } + + protected override void LoadComplete() + { + chatBoxManager.SelectedClip.BindValueChanged(e => + { + ((Container)Child).BorderThickness = Clip == e.NewValue ? 4 : 2; + }, true); + + Clip.Name.BindValueChanged(e => drawName.Text = e.NewValue, true); + Clip.Enabled.BindValueChanged(e => Child.FadeTo(e.NewValue ? 1 : 0.5f), true); + + updateSizeAndPosition(); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + chatBoxManager.SelectedClip.Value = Clip; + + if (e.Button == MouseButton.Left) + { + timelineEditor.HideClipMenu(); + } + else if (e.Button == MouseButton.Right) + { + timelineEditor.ShowClipMenu(Clip, e); + } + + return true; + } + + protected override bool OnDragStart(DragStartEvent e) => true; + + protected override void OnDrag(DragEvent e) + { + base.OnDrag(e); + + chatBoxManager.SelectedClip.Value = Clip; + + e.Target = Parent; + + var deltaX = e.Delta.X / Parent.DrawWidth; + cumulativeDrag += deltaX; + + if (Math.Abs(cumulativeDrag) >= chatBoxManager.TimelineResolution) + { + var newStart = Clip.Start.Value + Math.Sign(cumulativeDrag); + var newEnd = Clip.End.Value + Math.Sign(cumulativeDrag); + + var (lowerBound, _) = timelineLayer.GetBoundsNearestTo(Clip.Start.Value, false); + var (_, upperBound) = timelineLayer.GetBoundsNearestTo(Clip.End.Value, true); + + if (newStart >= lowerBound && newEnd <= upperBound) + { + Clip.Start.Value = newStart; + Clip.End.Value = newEnd; + } + + cumulativeDrag = 0f; + } + + updateSizeAndPosition(); + } + + private void updateSizeAndPosition() + { + Width = Clip.Length * chatBoxManager.TimelineResolution; + X = Clip.Start.Value * chatBoxManager.TimelineResolution; + } + + private partial class ResizeDetector : Container + { + protected readonly Clip Clip; + protected readonly Func NormaliseFunc; + + protected float CumulativeDrag; + + public ResizeDetector(Clip clip, Func normaliseFunc) + { + Clip = clip; + NormaliseFunc = normaliseFunc; + } + + [BackgroundDependencyLoader] + private void load() + { + Child = new Box + { + Colour = Color4.Black.Opacity(0.2f), + RelativeSizeAxes = Axes.Both + }; + } + + protected override bool OnDragStart(DragStartEvent e) => true; + + protected override bool OnHover(HoverEvent e) + { + Child.FadeColour(Colour4.Black.Opacity(0.5f), 100); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + Child.FadeColour(Colour4.Black.Opacity(0.2f), 100); + } + } + + private partial class StartResizeDetector : ResizeDetector + { + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + [Resolved] + private DrawableClip parentDrawableClip { get; set; } = null!; + + [Resolved] + private TimelineLayer timelineLayer { get; set; } = null!; + + public StartResizeDetector(Clip clip, Func normaliseFunc) + : base(clip, normaliseFunc) + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + } + + protected override void OnDrag(DragEvent e) + { + base.OnDrag(e); + + e.Target = Parent.Parent; + CumulativeDrag += NormaliseFunc.Invoke(e.Delta.X); + + if (Math.Abs(CumulativeDrag) >= chatBoxManager.TimelineResolution) + { + var newStart = Clip.Start.Value + Math.Sign(CumulativeDrag); + + var (lowerBound, upperBound) = timelineLayer.GetBoundsNearestTo(float.IsNegative(CumulativeDrag) ? Clip.Start.Value : newStart, false); + + if (newStart >= lowerBound && newStart < upperBound) + { + Clip.Start.Value = newStart; + } + + CumulativeDrag = 0f; + } + + parentDrawableClip.updateSizeAndPosition(); + } + } + + private partial class EndResizeDetector : ResizeDetector + { + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + [Resolved] + private DrawableClip parentDrawableClip { get; set; } = null!; + + [Resolved] + private TimelineLayer timelineLayer { get; set; } = null!; + + public EndResizeDetector(Clip clip, Func normaliseFunc) + : base(clip, normaliseFunc) + { + Anchor = Anchor.CentreRight; + Origin = Anchor.CentreRight; + } + + protected override void OnDrag(DragEvent e) + { + base.OnDrag(e); + + e.Target = Parent.Parent; + CumulativeDrag += NormaliseFunc.Invoke(e.Delta.X); + + if (Math.Abs(CumulativeDrag) >= chatBoxManager.TimelineResolution) + { + var newEnd = Clip.End.Value + Math.Sign(CumulativeDrag); + + var (lowerBound, upperBound) = timelineLayer.GetBoundsNearestTo(float.IsNegative(CumulativeDrag) ? newEnd : Clip.End.Value, true); + + if (newEnd > lowerBound && newEnd <= upperBound) + { + Clip.End.Value = newEnd; + } + + CumulativeDrag = 0f; + } + + parentDrawableClip.updateSizeAndPosition(); + } + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/Clip/TimelineClipMenu.cs b/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/Clip/TimelineClipMenu.cs new file mode 100644 index 00000000..9e1c132c --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/Clip/TimelineClipMenu.cs @@ -0,0 +1,79 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using VRCOSC.Game.ChatBox; + +namespace VRCOSC.Game.Graphics.ChatBox.Timeline.Menu.Clip; + +public partial class TimelineClipMenu : TimelineMenu +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + private Game.ChatBox.Clips.Clip clip { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Add(new Container + { + RelativeSizeAxes = Axes.X, + Height = 25, + Child = new MenuButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Move Up", + FontSize = 20, + RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + Action = () => chatBoxManager.IncreasePriority(clip) + } + }); + + Add(new Container + { + RelativeSizeAxes = Axes.X, + Height = 25, + Child = new MenuButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Move Down", + FontSize = 20, + RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + Action = () => chatBoxManager.DecreasePriority(clip) + } + }); + + Add(new Container + { + RelativeSizeAxes = Axes.X, + Height = 25, + Child = new MenuButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Delete", + FontSize = 20, + RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + Action = () => + { + chatBoxManager.DeleteClip(clip); + if (chatBoxManager.SelectedClip.Value == clip) chatBoxManager.SelectedClip.Value = null; + Hide(); + } + } + }); + } + + public void SetClip(Game.ChatBox.Clips.Clip clip) + { + this.clip = clip; + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/Layer/TimelineLayerMenu.cs b/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/Layer/TimelineLayerMenu.cs new file mode 100644 index 00000000..0b2c5718 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/Layer/TimelineLayerMenu.cs @@ -0,0 +1,53 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using VRCOSC.Game.ChatBox; + +namespace VRCOSC.Game.Graphics.ChatBox.Timeline.Menu.Layer; + +public partial class TimelineLayerMenu : TimelineMenu +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + public int XPos; + public TimelineLayer Layer { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + Add(new Container + { + RelativeSizeAxes = Axes.X, + Height = 25, + Child = new MenuButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Add Clip", + FontSize = 20, + RelativeSizeAxes = Axes.Both, + CornerRadius = 5, + Action = createClip + } + }); + } + + private void createClip() + { + var clip = chatBoxManager.CreateClip(); + + var (lowerBound, upperBound) = Layer.GetBoundsNearestTo(XPos, false, true); + + clip.Start.Value = lowerBound; + clip.End.Value = upperBound; + clip.Priority.Value = Layer.Priority; + + chatBoxManager.Clips.Add(clip); + + Hide(); + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/MenuButton.cs b/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/MenuButton.cs new file mode 100644 index 00000000..41e0a7cd --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/MenuButton.cs @@ -0,0 +1,16 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Graphics.UI.Button; + +namespace VRCOSC.Game.Graphics.ChatBox.Timeline.Menu; + +public partial class MenuButton : TextButton +{ + public MenuButton() + { + Stateful = false; + BackgroundColour = ThemeManager.Current[ThemeAttribute.Accent]; + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/TimelineMenu.cs b/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/TimelineMenu.cs new file mode 100644 index 00000000..60af3431 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Timeline/Menu/TimelineMenu.cs @@ -0,0 +1,73 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Platform; +using osuTK; +using VRCOSC.Game.Graphics.Themes; + +namespace VRCOSC.Game.Graphics.ChatBox.Timeline.Menu; + +public abstract partial class TimelineMenu : VisibilityContainer +{ + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved] + private ChatBoxScreen chatBoxScreen { get; set; } = null!; + + protected override FillFlowContainer Content { get; } + + protected TimelineMenu() + { + InternalChild = new Container + { + Width = 200, + AutoSizeAxes = Axes.Y, + BorderThickness = 2, + Masking = true, + CornerRadius = 5, + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Dark].Opacity(0.5f), + RelativeSizeAxes = Axes.Both + }, + Content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Spacing = new Vector2(0, 10) + } + } + }; + } + + protected override void PopIn() + { + this.FadeInFromZero(100, Easing.OutQuad); + + if (Position.Y + InternalChild.DrawHeight < host.Window.ClientSize.Height) + InternalChild.Origin = InternalChild.Anchor = Position.X + InternalChild.DrawWidth < host.Window.ClientSize.Width ? Anchor.TopLeft : Anchor.TopRight; + else + InternalChild.Origin = InternalChild.Anchor = Position.X + InternalChild.DrawWidth < host.Window.ClientSize.Width ? Anchor.BottomLeft : Anchor.BottomRight; + } + + protected override void PopOut() + { + this.FadeOutFromOne(100, Easing.OutQuad); + } + + public void SetPosition(MouseDownEvent e) + { + e.Target = chatBoxScreen; + Position = e.MousePosition; + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Timeline/TimelineEditor.cs b/VRCOSC.Game/Graphics/ChatBox/Timeline/TimelineEditor.cs new file mode 100644 index 00000000..6bc94b4b --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Timeline/TimelineEditor.cs @@ -0,0 +1,216 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osuTK; +using osuTK.Input; +using VRCOSC.Game.ChatBox; +using VRCOSC.Game.ChatBox.Clips; +using VRCOSC.Game.Graphics.ChatBox.Timeline.Menu.Clip; +using VRCOSC.Game.Graphics.ChatBox.Timeline.Menu.Layer; +using VRCOSC.Game.Graphics.Themes; +using VRCOSC.Game.Modules; + +namespace VRCOSC.Game.Graphics.ChatBox.Timeline; + +[Cached] +public partial class TimelineEditor : Container +{ + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + + [Resolved] + private GameManager gameManager { get; set; } = null!; + + [Resolved] + private TimelineLayerMenu layerMenu { get; set; } = null!; + + [Resolved] + private TimelineClipMenu clipMenu { get; set; } = null!; + + private const int grid_line_width = 3; + + private Dictionary layers = new(); + private Container gridGenerator = null!; + private Container positionIndicator = null!; + + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + CornerRadius = 10; + + GridContainer layerContainer; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Mid], + RelativeSizeAxes = Axes.Both + }, + gridGenerator = new Container + { + RelativeSizeAxes = Axes.Both, + Position = new Vector2(-(grid_line_width / 2f)) + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Child = layerContainer = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(), + new Dimension(), + new Dimension(), + new Dimension(), + new Dimension(), + new Dimension(), + }, + Content = new[] + { + new Drawable[] { new TimelineLayer(5) }, + new Drawable[] { new TimelineLayer(4) }, + new Drawable[] { new TimelineLayer(3) }, + new Drawable[] { new TimelineLayer(2) }, + new Drawable[] { new TimelineLayer(1) }, + new Drawable[] { new TimelineLayer(0) } + } + } + }, + positionIndicator = new Container + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + Width = 5, + CornerRadius = 2, + EdgeEffect = VRCOSCEdgeEffects.UniformShadow, + Masking = true, + Child = new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Accent], + RelativeSizeAxes = Axes.Both + } + } + }; + + foreach (var array in layerContainer.Content) + { + var timelineLayer = (TimelineLayer)array[0]; + layers.Add(timelineLayer.Priority, timelineLayer); + } + + chatBoxManager.Clips.BindCollectionChanged((_, e) => + { + if (e.OldItems is not null) + { + foreach (Clip oldClip in e.OldItems) + { + layers[oldClip.Priority.Value].Remove(oldClip); + } + } + + if (e.NewItems is not null) + { + foreach (Clip newClip in e.NewItems) + { + layers[newClip.Priority.Value].Add(newClip); + + newClip.Priority.BindValueChanged(priorityValue => + { + layers[priorityValue.OldValue].Remove(newClip); + layers[priorityValue.NewValue].Add(newClip); + }); + } + } + }, true); + + generateGrid(); + } + + protected override void Update() + { + if (gameManager.State.Value == GameManagerState.Started) + { + positionIndicator.Alpha = 1; + positionIndicator.X = chatBoxManager.CurrentPercentage; + } + else + { + positionIndicator.Alpha = 0; + } + } + + private void generateGrid() + { + for (var i = 0; i < 60; i++) + { + gridGenerator.Add(new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Dark].Opacity(0.5f), + RelativeSizeAxes = Axes.Y, + RelativePositionAxes = Axes.X, + Width = grid_line_width, + X = (chatBoxManager.TimelineResolution * i) + }); + } + + for (var i = 0; i < 6; i++) + { + gridGenerator.Add(new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Dark].Opacity(0.5f), + RelativeSizeAxes = Axes.X, + RelativePositionAxes = Axes.Y, + Height = grid_line_width, + Y = (DrawHeight / 6 * i) + }); + } + } + + public void ShowLayerMenu(MouseDownEvent e, int xPos, TimelineLayer layer) + { + clipMenu.Hide(); + layerMenu.Hide(); + layerMenu.SetPosition(e); + layerMenu.XPos = xPos; + layerMenu.Layer = layer; + layerMenu.Show(); + } + + public void HideClipMenu() + { + clipMenu.Hide(); + } + + public void ShowClipMenu(Clip clip, MouseDownEvent e) + { + layerMenu.Hide(); + clipMenu.Hide(); + clipMenu.SetClip(clip); + clipMenu.SetPosition(e); + clipMenu.Show(); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left) + { + chatBoxManager.SelectedClip.Value = null; + clipMenu.Hide(); + layerMenu.Hide(); + } + + return true; + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Timeline/TimelineLayer.cs b/VRCOSC.Game/Graphics/ChatBox/Timeline/TimelineLayer.cs new file mode 100644 index 00000000..29645ed1 --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Timeline/TimelineLayer.cs @@ -0,0 +1,93 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osuTK.Input; +using VRCOSC.Game.ChatBox.Clips; + +namespace VRCOSC.Game.Graphics.ChatBox.Timeline; + +[Cached] +public partial class TimelineLayer : Container +{ + [Resolved] + private TimelineEditor timelineEditor { get; set; } = null!; + + public readonly int Priority; + + public TimelineLayer(int priority) + { + Priority = priority; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 10; + } + + public void Add(Clip clip) + { + Add(new DrawableClip(clip)); + } + + public void Remove(Clip clip) + { + Children.ForEach(child => + { + if (child.Clip == clip) Schedule(child.RemoveAndDisposeImmediately); + }); + } + + public (int, int) GetBoundsNearestTo(int value, bool end, bool isCreating = false) + { + var boundsList = new List(); + + Children.ForEach(child => + { + var clip = child.Clip; + + if (end) + { + if (clip.End.Value != value) boundsList.Add(clip.End.Value); + boundsList.Add(clip.Start.Value); + } + else + { + if (clip.Start.Value != value) boundsList.Add(clip.Start.Value); + boundsList.Add(clip.End.Value); + } + }); + + boundsList.Add(0); + boundsList.Add(60); + boundsList.Sort(); + + var lowerBound = boundsList.Last(bound => bound <= value); + var upperBound = boundsList.First(bound => bound >= value && (!isCreating || bound > lowerBound)); + + return (lowerBound, upperBound); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Right) + { + e.Target = this; + var xPos = (int)MathF.Floor(e.MousePosition.X / DrawWidth * 60); + timelineEditor.ShowLayerMenu(e, xPos, this); + return true; + } + + return base.OnMouseDown(e); + } +} diff --git a/VRCOSC.Game/Graphics/ChatBox/Timeline/TimelineWrapper.cs b/VRCOSC.Game/Graphics/ChatBox/Timeline/TimelineWrapper.cs new file mode 100644 index 00000000..668f9a5b --- /dev/null +++ b/VRCOSC.Game/Graphics/ChatBox/Timeline/TimelineWrapper.cs @@ -0,0 +1,40 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using VRCOSC.Game.Graphics.Themes; + +namespace VRCOSC.Game.Graphics.ChatBox.Timeline; + +public partial class TimelineWrapper : Container +{ + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + CornerRadius = 10; + + Children = new Drawable[] + { + new Box + { + Colour = ThemeManager.Current[ThemeAttribute.Dark], + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(5), + Child = new TimelineEditor + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both + } + } + }; + } +} diff --git a/VRCOSC.Game/Graphics/LineSeparator.cs b/VRCOSC.Game/Graphics/LineSeparator.cs index 3e46ca1c..2254b463 100644 --- a/VRCOSC.Game/Graphics/LineSeparator.cs +++ b/VRCOSC.Game/Graphics/LineSeparator.cs @@ -11,11 +11,18 @@ namespace VRCOSC.Game.Graphics; public sealed partial class LineSeparator : CircularContainer { + public Colour4 LineColour + { + get => Child.Colour; + set => Child.Colour = value; + } + public LineSeparator() { RelativeSizeAxes = Axes.X; Size = new Vector2(0.95f, 5); Masking = true; + Child = new Box { Anchor = Anchor.Centre, diff --git a/VRCOSC.Game/Graphics/MainContent.cs b/VRCOSC.Game/Graphics/MainContent.cs index 192ce39f..0ac92d01 100644 --- a/VRCOSC.Game/Graphics/MainContent.cs +++ b/VRCOSC.Game/Graphics/MainContent.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using VRCOSC.Game.Graphics.About; +using VRCOSC.Game.Graphics.ChatBox; using VRCOSC.Game.Graphics.ModuleListing; using VRCOSC.Game.Graphics.Router; using VRCOSC.Game.Graphics.Settings; @@ -51,6 +52,7 @@ private void load() Children = new Drawable[] { new ModuleListingScreen(), + new ChatBoxScreen(), new SettingsScreen(), new RouterScreen(), new AboutScreen() diff --git a/VRCOSC.Game/Graphics/ModuleEditing/Attributes/Text/TextAttributeCard.cs b/VRCOSC.Game/Graphics/ModuleEditing/Attributes/Text/TextAttributeCard.cs index 729440a6..01651d93 100644 --- a/VRCOSC.Game/Graphics/ModuleEditing/Attributes/Text/TextAttributeCard.cs +++ b/VRCOSC.Game/Graphics/ModuleEditing/Attributes/Text/TextAttributeCard.cs @@ -40,7 +40,7 @@ private void load() protected override void LoadComplete() { base.LoadComplete(); - textBox.OnValidEntry += entry => UpdateAttribute(entry); + textBox.OnValidEntry += entry => UpdateAttribute(entry!); } protected override void SetDefault() diff --git a/VRCOSC.Game/Graphics/ModuleEditing/ModuleEditingPopover.cs b/VRCOSC.Game/Graphics/ModuleEditing/ModuleEditingPopover.cs index 37b89f19..5cc1b134 100644 --- a/VRCOSC.Game/Graphics/ModuleEditing/ModuleEditingPopover.cs +++ b/VRCOSC.Game/Graphics/ModuleEditing/ModuleEditingPopover.cs @@ -15,6 +15,9 @@ public sealed partial class ModuleEditingPopover : PopoverScreen [Resolved(name: "EditingModule")] private Bindable editingModule { get; set; } = null!; + [Resolved] + private GameManager gameManager { get; set; } = null!; + public ModuleEditingPopover() { Children = new Drawable[] @@ -43,7 +46,9 @@ protected override void LoadComplete() { if (e.NewValue is null) { - e.OldValue?.Save(); + if (e.OldValue is not null) + gameManager.ModuleManager.Save(e.OldValue); + Hide(); } else diff --git a/VRCOSC.Game/Graphics/ModuleListing/ModuleCard.cs b/VRCOSC.Game/Graphics/ModuleListing/ModuleCard.cs index 17472bfa..3af6f3fe 100644 --- a/VRCOSC.Game/Graphics/ModuleListing/ModuleCard.cs +++ b/VRCOSC.Game/Graphics/ModuleListing/ModuleCard.cs @@ -81,7 +81,7 @@ public ModuleCard(Module module) Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, ShouldAnimate = false, - State = (BindableBool)Module.Enabled.GetBoundCopy() + State = Module.Enabled.GetBoundCopy() } }, new FillFlowContainer @@ -152,7 +152,7 @@ public ModuleCard(Module module) Origin = Anchor.CentreRight, RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fit, - Alpha = Module.HasSettings ? 1 : 0.5f, + Alpha = Module.HasSettings || Module.HasParameters ? 1 : 0.5f, Child = new IconButton { Anchor = Anchor.Centre, @@ -163,7 +163,7 @@ public ModuleCard(Module module) CornerRadius = 5, Action = () => editingModule.Value = Module, BackgroundColour = ThemeManager.Current[ThemeAttribute.Light], - Enabled = { Value = Module.HasSettings } + Enabled = { Value = Module.HasSettings || Module.HasParameters } } } } diff --git a/VRCOSC.Game/Graphics/ModuleRun/ModuleRunPopover.cs b/VRCOSC.Game/Graphics/ModuleRun/ModuleRunPopover.cs index ec301e11..0f77f0ce 100644 --- a/VRCOSC.Game/Graphics/ModuleRun/ModuleRunPopover.cs +++ b/VRCOSC.Game/Graphics/ModuleRun/ModuleRunPopover.cs @@ -37,14 +37,26 @@ public ModuleRunPopover() ColumnDimensions = new[] { new Dimension(GridSizeMode.Relative, 0.35f), + new Dimension(GridSizeMode.Absolute, 15), new Dimension() }, Content = new[] { - new Drawable[] + new Drawable?[] { - terminal = new TerminalContainer(), - parameters = new ParameterContainer() + terminal = new TerminalContainer + { + RelativeSizeAxes = Axes.Both, + BorderThickness = 3, + Masking = true, + }, + null, + parameters = new ParameterContainer + { + RelativeSizeAxes = Axes.Both, + BorderThickness = 3, + Masking = true, + } } } } diff --git a/VRCOSC.Game/Graphics/ModuleRun/ParameterContainer.cs b/VRCOSC.Game/Graphics/ModuleRun/ParameterContainer.cs index 80f7d850..8d471112 100644 --- a/VRCOSC.Game/Graphics/ModuleRun/ParameterContainer.cs +++ b/VRCOSC.Game/Graphics/ModuleRun/ParameterContainer.cs @@ -21,18 +21,13 @@ public sealed partial class ParameterContainer : Container public ParameterContainer() { - RelativeSizeAxes = Axes.Both; - Padding = new MarginPadding - { - Left = 15 / 2f - }; - Child = new GridContainer { RelativeSizeAxes = Axes.Both, RowDimensions = new[] { new Dimension(), + new Dimension(GridSizeMode.Absolute, 15), new Dimension() }, Content = new[] @@ -41,22 +36,21 @@ public ParameterContainer() { outgoingParameterDisplay = new ParameterSubContainer { - Title = "Outgoing", - Padding = new MarginPadding - { - Bottom = 15 / 2f - } + RelativeSizeAxes = Axes.Both, + BorderThickness = 3, + Masking = true, + Title = "Outgoing" } }, + null, new Drawable[] { incomingParameterDisplay = new ParameterSubContainer { - Title = "Incoming", - Padding = new MarginPadding - { - Top = 15 / 2f - } + RelativeSizeAxes = Axes.Both, + BorderThickness = 3, + Masking = true, + Title = "Incoming" } } } @@ -94,30 +88,22 @@ private sealed partial class ParameterSubContainer : Container [BackgroundDependencyLoader] private void load() { - RelativeSizeAxes = Axes.Both; - - Child = new Container + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - BorderThickness = 3, - Masking = true, - Children = new Drawable[] + new Box { - new Box + RelativeSizeAxes = Axes.Both, + Colour = ThemeManager.Current[ThemeAttribute.Darker] + }, + parameterDisplay = new ParameterDisplay + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { - RelativeSizeAxes = Axes.Both, - Colour = ThemeManager.Current[ThemeAttribute.Darker] + Vertical = 1.5f, + Horizontal = 3 }, - parameterDisplay = new ParameterDisplay - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Vertical = 1.5f, - Horizontal = 3 - }, - Title = Title - } + Title = Title } }; } diff --git a/VRCOSC.Game/Graphics/ModuleRun/ParameterDisplay.cs b/VRCOSC.Game/Graphics/ModuleRun/ParameterDisplay.cs index f9b71725..508dd474 100644 --- a/VRCOSC.Game/Graphics/ModuleRun/ParameterDisplay.cs +++ b/VRCOSC.Game/Graphics/ModuleRun/ParameterDisplay.cs @@ -4,16 +4,21 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using VRCOSC.Game.Graphics.TabBar; using VRCOSC.Game.Graphics.Themes; namespace VRCOSC.Game.Graphics.ModuleRun; public sealed partial class ParameterDisplay : Container { + [Resolved] + private Bindable selectedTab { get; set; } = null!; + public string Title { get; init; } = null!; private readonly SortedDictionary parameterDict = new(); @@ -88,32 +93,36 @@ public void ClearContent() parameterDict.Clear(); } - public void AddEntry(string key, object value) => Schedule(() => + public void AddEntry(string key, object value) { - var valueStr = value.ToString() ?? "Invalid Object"; + if (selectedTab.Value != Tab.Modules) return; - if (parameterDict.ContainsKey(key)) - { - var existingEntry = parameterDict[key]; - existingEntry.Value.Value = valueStr; - } - else + Schedule(() => { - var newEntry = new ParameterEntry + var valueStr = value.ToString() ?? "Invalid Object"; + + if (parameterDict.TryGetValue(key, out var existingEntry)) + { + existingEntry.Value.Value = valueStr; + } + else { - Key = key, - Value = { Value = valueStr } - }; + var newEntry = new ParameterEntry + { + Key = key, + Value = { Value = valueStr } + }; - parameterDict.Add(key, newEntry); - parameterFlow.Add(newEntry); + parameterDict.Add(key, newEntry); + parameterFlow.Add(newEntry); - parameterDict.ForEach(pair => - { - var (_, entry) = pair; - var positionOfEntry = parameterDict.Values.ToList().IndexOf(entry); - parameterFlow.SetLayoutPosition(entry, parameterDict.Count - positionOfEntry); - }); - } - }); + parameterDict.ForEach(pair => + { + var (_, entry) = pair; + var positionOfEntry = parameterDict.Values.ToList().IndexOf(entry); + parameterFlow.SetLayoutPosition(entry, parameterDict.Count - positionOfEntry); + }); + } + }); + } } diff --git a/VRCOSC.Game/Graphics/ModuleRun/TerminalContainer.cs b/VRCOSC.Game/Graphics/ModuleRun/TerminalContainer.cs index d3339c5d..0be13d2b 100644 --- a/VRCOSC.Game/Graphics/ModuleRun/TerminalContainer.cs +++ b/VRCOSC.Game/Graphics/ModuleRun/TerminalContainer.cs @@ -21,48 +21,32 @@ public sealed partial class TerminalContainer : Container public TerminalContainer() { - RelativeSizeAxes = Axes.Both; - Padding = new MarginPadding + InternalChildren = new Drawable[] { - Right = 15 / 2f - }; - - InternalChild = new Container - { - RelativeSizeAxes = Axes.Both, - Child = new Container + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ThemeManager.Current[ThemeAttribute.Darker] + }, + new Container { RelativeSizeAxes = Axes.Both, - BorderThickness = 3, - Masking = true, - Children = new Drawable[] + Padding = new MarginPadding(3f), + Child = terminalScroll = new BasicScrollContainer { - new Box + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + ClampExtension = 0, + Child = Content = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - Colour = ThemeManager.Current[ThemeAttribute.Darker] - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(3f), - Child = terminalScroll = new BasicScrollContainer + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - ClampExtension = 0, - Child = Content = new FillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Padding = new MarginPadding - { - Horizontal = 3 - } - } + Horizontal = 3 } } } @@ -99,12 +83,17 @@ protected override void UpdateAfterChildren() terminalScroll.ScrollToEnd(); } - private void log(string text) => Schedule(() => + private void log(string text) { - var entry = terminalEntryPool.Get(); - entry.Text = $"[{DateTime.Now:HH:mm:ss}] {text}"; - Add(entry); - entry.Hide(); - entry.Show(); - }); + var dateTimeText = $"[{DateTime.Now:HH:mm:ss}] {text}"; + + Schedule(() => + { + var entry = terminalEntryPool.Get(); + entry.Text = dateTimeText; + Add(entry); + entry.Hide(); + entry.Show(); + }); + } } diff --git a/VRCOSC.Game/Graphics/Settings/AutomationSection.cs b/VRCOSC.Game/Graphics/Settings/AutomationSection.cs index ab147a8d..18cc830b 100644 --- a/VRCOSC.Game/Graphics/Settings/AutomationSection.cs +++ b/VRCOSC.Game/Graphics/Settings/AutomationSection.cs @@ -11,7 +11,7 @@ public sealed partial class AutomationSection : SectionContainer protected override void GenerateItems() { - AddToggle("Start/Stop with VRChat", "Auto start/stop modules on VRChat open/close", ConfigManager.GetBindable(VRCOSCSetting.AutoStartStop)); + AddToggle("Start/Stop with VRChat", "Auto start/stop modules on VRChat open/close (Note: Disables run button from being used manually)", ConfigManager.GetBindable(VRCOSCSetting.AutoStartStop)); AddToggle("Open with SteamVR", "Should VRCOSC open when SteamVR launches? Requires a manual launch when changing to true", ConfigManager.GetBindable(VRCOSCSetting.AutoStartOpenVR)); AddToggle("Close with SteamVR", "Should VRCOSC close when SteamVR closes?", ConfigManager.GetBindable(VRCOSCSetting.AutoStopOpenVR)); } diff --git a/VRCOSC.Game/Graphics/TabBar/DrawableTab.cs b/VRCOSC.Game/Graphics/TabBar/DrawableTab.cs index 68517fcc..ba26cec2 100644 --- a/VRCOSC.Game/Graphics/TabBar/DrawableTab.cs +++ b/VRCOSC.Game/Graphics/TabBar/DrawableTab.cs @@ -104,17 +104,6 @@ protected override void LoadComplete() Action += () => selectedTab.Value = Tab; } - protected override bool OnClick(ClickEvent e) - { - if (gameManager.State.Value is GameManagerState.Starting or GameManagerState.Started) - { - background.FlashColour(ThemeManager.Current[ThemeAttribute.Failure], 250, Easing.OutQuad); - return true; - } - - return base.OnClick(e); - } - protected override bool OnHover(HoverEvent e) { background.FadeColour(hover_colour, onhover_duration, Easing.InOutSine); diff --git a/VRCOSC.Game/Graphics/TabBar/Tab.cs b/VRCOSC.Game/Graphics/TabBar/Tab.cs index 97841b24..22dfe81d 100644 --- a/VRCOSC.Game/Graphics/TabBar/Tab.cs +++ b/VRCOSC.Game/Graphics/TabBar/Tab.cs @@ -6,6 +6,7 @@ namespace VRCOSC.Game.Graphics.TabBar; public enum Tab { Modules, + ChatBox, Settings, Router, About diff --git a/VRCOSC.Game/Graphics/TabBar/TabSelector.cs b/VRCOSC.Game/Graphics/TabBar/TabSelector.cs index c95df942..5ab156bb 100644 --- a/VRCOSC.Game/Graphics/TabBar/TabSelector.cs +++ b/VRCOSC.Game/Graphics/TabBar/TabSelector.cs @@ -17,6 +17,7 @@ public sealed partial class TabSelector : Container private static readonly IReadOnlyDictionary icon_lookup = new Dictionary { { Tab.Modules, FontAwesome.Solid.ListUl }, + { Tab.ChatBox, FontAwesome.Solid.Get(62074) }, { Tab.Settings, FontAwesome.Solid.Cog }, { Tab.Router, FontAwesome.Solid.Get(61920) }, { Tab.About, FontAwesome.Solid.Info } diff --git a/VRCOSC.Game/Graphics/UI/Button/TextButton.cs b/VRCOSC.Game/Graphics/UI/Button/TextButton.cs index c3008930..d75aed67 100644 --- a/VRCOSC.Game/Graphics/UI/Button/TextButton.cs +++ b/VRCOSC.Game/Graphics/UI/Button/TextButton.cs @@ -8,7 +8,7 @@ namespace VRCOSC.Game.Graphics.UI.Button; -public sealed partial class TextButton : BasicButton +public partial class TextButton : BasicButton { private string text = string.Empty; diff --git a/VRCOSC.Game/Graphics/UI/Button/ToggleButton.cs b/VRCOSC.Game/Graphics/UI/Button/ToggleButton.cs index 29076866..e05c9217 100644 --- a/VRCOSC.Game/Graphics/UI/Button/ToggleButton.cs +++ b/VRCOSC.Game/Graphics/UI/Button/ToggleButton.cs @@ -14,7 +14,7 @@ namespace VRCOSC.Game.Graphics.UI.Button; public sealed partial class ToggleButton : VRCOSCButton { - public BindableBool State { get; init; } = new(); + public Bindable State { get; init; } = new(); public ToggleButton() { @@ -26,6 +26,7 @@ public ToggleButton() private void load() { SpriteIcon icon; + Children = new Drawable[] { new Box @@ -58,7 +59,7 @@ private void load() protected override bool OnClick(ClickEvent e) { - State.Toggle(); + State.Value = !State.Value; return base.OnClick(e); } } diff --git a/VRCOSC.Game/Graphics/UI/VRCOSCScrollContainer.cs b/VRCOSC.Game/Graphics/UI/VRCOSCScrollContainer.cs index b2f0e053..38950e33 100644 --- a/VRCOSC.Game/Graphics/UI/VRCOSCScrollContainer.cs +++ b/VRCOSC.Game/Graphics/UI/VRCOSCScrollContainer.cs @@ -20,6 +20,8 @@ public VRCOSCScrollContainer(Direction scrollDirection = Direction.Vertical) public partial class VRCOSCScrollContainer : ScrollContainer where T : Drawable { + public bool ShowScrollbar { get; init; } = true; + protected VRCOSCScrollContainer(Direction scrollDirection = Direction.Vertical) : base(scrollDirection) { @@ -30,7 +32,7 @@ protected override void UpdateAfterChildren() base.UpdateAfterChildren(); // we always want this to show - Scrollbar.Show(); + if (ShowScrollbar) Scrollbar.Show(); } protected override ScrollbarContainer CreateScrollbar(Direction direction) => new VRCOSCScrollbar(direction); diff --git a/VRCOSC.Game/Graphics/VRCOSCEdgeEffects.cs b/VRCOSC.Game/Graphics/VRCOSCEdgeEffects.cs index 0307e6b8..a5656901 100644 --- a/VRCOSC.Game/Graphics/VRCOSCEdgeEffects.cs +++ b/VRCOSC.Game/Graphics/VRCOSCEdgeEffects.cs @@ -15,7 +15,7 @@ public static class VRCOSCEdgeEffects public static readonly EdgeEffectParameters NoShadow = new() { Colour = new Color4(0, 0, 0, 0), - Radius = 0, + Radius = 0f, Type = EdgeEffectType.Shadow, Offset = Vector2.Zero }; @@ -28,11 +28,19 @@ public static class VRCOSCEdgeEffects Offset = new Vector2(0.0f, 1.5f) }; + public static readonly EdgeEffectParameters UniformShadow = new() + { + Colour = Color4.Black.Opacity(0.6f), + Radius = 5f, + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0f) + }; + public static readonly EdgeEffectParameters DispersedShadow = new() { Colour = Color4.Black.Opacity(0.75f), - Radius = 15, + Radius = 15f, Type = EdgeEffectType.Shadow, - Offset = new Vector2(0) + Offset = new Vector2(0f) }; } diff --git a/VRCOSC.Game/Modules/ChatBox/ChatBoxModule.cs b/VRCOSC.Game/Modules/ChatBox/ChatBoxModule.cs new file mode 100644 index 00000000..ba54fa6e --- /dev/null +++ b/VRCOSC.Game/Modules/ChatBox/ChatBoxModule.cs @@ -0,0 +1,21 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System; + +namespace VRCOSC.Game.Modules.ChatBox; + +public abstract class ChatBoxModule : Module +{ + protected void CreateVariable(Enum lookup, string name, string format) => ChatBoxManager.RegisterVariable(SerialisedName, lookup.ToLookup(), name, format); + protected void CreateState(Enum lookup, string name, string defaultFormat) => ChatBoxManager.RegisterState(SerialisedName, lookup.ToLookup(), name, defaultFormat); + protected void CreateEvent(Enum lookup, string name, string defaultFormat, int defaultLength) => ChatBoxManager.RegisterEvent(SerialisedName, lookup.ToLookup(), name, defaultFormat, defaultLength); + + protected void SetVariableValue(Enum lookup, string? value) => ChatBoxManager.SetVariable(SerialisedName, lookup.ToLookup(), value); + protected string GetVariableFormat(Enum lookup) => ChatBoxManager.VariableMetadata[SerialisedName][lookup.ToLookup()].DisplayableFormat; + + protected void ChangeStateTo(Enum lookup) => ChatBoxManager.ChangeStateTo(SerialisedName, lookup.ToLookup()); + protected void TriggerEvent(Enum lookup) => ChatBoxManager.TriggerEvent(SerialisedName, lookup.ToLookup()); + + protected void SetChatBoxTyping(bool typing) => ChatBoxManager.SetTyping(typing); +} diff --git a/VRCOSC.Game/Modules/ChatBoxInterface.cs b/VRCOSC.Game/Modules/ChatBoxInterface.cs deleted file mode 100644 index 86d5bb4d..00000000 --- a/VRCOSC.Game/Modules/ChatBoxInterface.cs +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. -// See the LICENSE file in the repository root for full license text. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; -using VRCOSC.Game.OSC.VRChat; - -namespace VRCOSC.Game.Modules; - -public class ChatBoxInterface -{ - private readonly ConcurrentQueue timedQueue = new(); - private readonly ConcurrentDictionary alwaysDict = new(); - private readonly VRChatOscClient oscClient; - private readonly IBindable resetMilli; - - private ChatBoxData? currentData; - private DateTimeOffset? sendReset; - private DateTimeOffset sendExpire; - private bool alreadyClear; - private bool running; - - private bool sendEnabled; - - public bool SendEnabled - { - get => sendEnabled; - set - { - sendEnabled = value; - if (!sendEnabled) clear(); - } - } - - public ChatBoxInterface(VRChatOscClient oscClient, IBindable resetMilli) - { - this.oscClient = oscClient; - this.resetMilli = resetMilli; - } - - public void SetTyping(bool typing) - { - oscClient.SendValue(VRChatOscConstants.ADDRESS_CHATBOX_TYPING, typing); - } - - public void Initialise() - { - currentData = null; - sendReset = null; - alreadyClear = true; - SendEnabled = true; - sendExpire = DateTimeOffset.Now; - timedQueue.Clear(); - alwaysDict.Clear(); - running = true; - } - - public void Shutdown() - { - running = false; - clear(); - } - - private void clear() - { - sendText(string.Empty); - alreadyClear = true; - } - - public DateTimeOffset SetText(string? text, int priority, TimeSpan displayLength) - { - var data = new ChatBoxData - { - Text = text, - DisplayLength = displayLength - }; - - // ChatBoxMode.Always - if (displayLength == TimeSpan.Zero) - { - alwaysDict[priority] = data; - return DateTimeOffset.Now; - } - - // ChatBoxMode.Timed - - if (text is null) return DateTimeOffset.Now; - - timedQueue.Enqueue(new ChatBoxData - { - Text = text, - DisplayLength = displayLength - }); - - var closestNextSendTime = DateTimeOffset.Now; - timedQueue.ForEach(d => closestNextSendTime += d.DisplayLength); - return closestNextSendTime; - } - - public void Update() - { - if (!running) return; - - switch (timedQueue.IsEmpty) - { - case true when sendExpire < DateTimeOffset.Now: - { - var validAlwaysData = alwaysDict.Where(data => data.Value.Text is not null).ToImmutableSortedDictionary(); - currentData = validAlwaysData.IsEmpty ? null : validAlwaysData.Last().Value; - break; - } - - case false: - { - if (sendExpire < DateTimeOffset.Now && timedQueue.TryDequeue(out var data)) - { - currentData = data; - sendExpire = DateTimeOffset.Now + data.DisplayLength; - } - - break; - } - } - - trySendText(); - } - - private void trySendText() - { - if (sendReset is not null && sendReset <= DateTimeOffset.Now) sendReset = null; - if (sendReset is not null) return; - - if (currentData is null) - { - if (!alreadyClear) - { - clear(); - sendReset = DateTimeOffset.Now + TimeSpan.FromMilliseconds(resetMilli.Value); - } - - return; - } - - alreadyClear = false; - - if (currentData.Text is null) return; - - if (sendEnabled) sendText(currentData.Text); - sendReset = DateTimeOffset.Now + TimeSpan.FromMilliseconds(resetMilli.Value); - } - - private void sendText(string text) - { - oscClient.SendValues(VRChatOscConstants.ADDRESS_CHATBOX_INPUT, new List { text, true, false }); - } - - private class ChatBoxData - { - public string? Text { get; init; } - public TimeSpan DisplayLength { get; init; } - } -} diff --git a/VRCOSC.Game/Modules/ChatBoxModule.cs b/VRCOSC.Game/Modules/ChatBoxModule.cs deleted file mode 100644 index 58fe398c..00000000 --- a/VRCOSC.Game/Modules/ChatBoxModule.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. -// See the LICENSE file in the repository root for full license text. - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace VRCOSC.Game.Modules; - -public abstract partial class ChatBoxModule : Module -{ - protected virtual bool DefaultChatBoxDisplay => true; - protected virtual IEnumerable ChatBoxFormatValues => Array.Empty(); - protected virtual string DefaultChatBoxFormat => string.Empty; - protected virtual int ChatBoxPriority => 0; - - private DateTimeOffset nextSendTime; - - protected override void CreateAttributes() - { - var chatboxFormat = ChatBoxFormatValues.Aggregate("How should details about this module be displayed in the ChatBox? Available values: ", (current, value) => current + $"{value}, ").Trim().TrimEnd(','); - CreateSetting(ChatBoxSetting.ChatBoxDisplay, "ChatBox Display", "If details about this module should be displayed in the ChatBox", DefaultChatBoxDisplay); - - if (ChatBoxFormatValues.Any()) - { - CreateSetting(ChatBoxSetting.ChatBoxFormat, "ChatBox Format", chatboxFormat, DefaultChatBoxFormat, - () => GetSetting(ChatBoxSetting.ChatBoxDisplay)); - } - - CreateSetting(ChatBoxSetting.ChatBoxMode, "ChatBox Mode", "Should this module display every X seconds or always show?", ChatBoxMode.Always, - () => GetSetting(ChatBoxSetting.ChatBoxDisplay)); - CreateSetting(ChatBoxSetting.ChatBoxTimer, "ChatBox Display Timer", $"How long should this module wait between showing details in the ChatBox? (sec)\nRequires ChatBox Mode to be {ChatBoxMode.Timed}", 60, - () => (GetSetting(ChatBoxSetting.ChatBoxDisplay) && GetSetting(ChatBoxSetting.ChatBoxMode) == ChatBoxMode.Timed)); - CreateSetting(ChatBoxSetting.ChatBoxLength, "ChatBox Display Length", $"How long should this module's details be shown in the ChatBox (sec)\nRequires ChatBox Mode to be {ChatBoxMode.Timed}", 5, - () => (GetSetting(ChatBoxSetting.ChatBoxDisplay) && GetSetting(ChatBoxSetting.ChatBoxMode) == ChatBoxMode.Timed)); - } - - protected override void OnModuleStart() - { - nextSendTime = DateTimeOffset.Now; - } - - protected override void Update() - { - if (State.Value == ModuleState.Started) trySend(); - } - - private void trySend() - { - if (!GetSetting(ChatBoxSetting.ChatBoxDisplay)) return; - if (GetSetting(ChatBoxSetting.ChatBoxMode) == ChatBoxMode.Timed && nextSendTime > DateTimeOffset.Now) return; - - var text = GetChatBoxText(); - - var displayTimerTimeSpan = TimeSpan.FromSeconds(GetSetting(ChatBoxSetting.ChatBoxTimer)); - var displayLengthTimeSpan = GetSetting(ChatBoxSetting.ChatBoxMode) == ChatBoxMode.Timed ? TimeSpan.FromSeconds(GetSetting(ChatBoxSetting.ChatBoxLength)) : TimeSpan.Zero; - - var closestNextSendTime = SetChatBoxText(text, displayLengthTimeSpan); - - if (GetSetting(ChatBoxSetting.ChatBoxMode) == ChatBoxMode.Always) - { - nextSendTime = DateTimeOffset.Now; - return; - } - - // Used for late loading modules - // I.E. HardwareStats doesn't start producing values for a few seconds but we want to queue it ASAP once it does - // so that the timing isn't messed up - if (GetSetting(ChatBoxSetting.ChatBoxMode) == ChatBoxMode.Timed && closestNextSendTime <= DateTimeOffset.Now) - { - nextSendTime = DateTimeOffset.Now; - return; - } - - if (closestNextSendTime >= DateTimeOffset.Now + displayTimerTimeSpan) - nextSendTime = closestNextSendTime; - else - nextSendTime = closestNextSendTime + (displayTimerTimeSpan - displayLengthTimeSpan); - } - - /// - /// Called to gather text to be put into the ChatBox. - /// Text is allowed to be empty. Null indicates that the module is in an invalid state to send text and will be denied ChatBox time. - /// - /// - protected abstract string? GetChatBoxText(); - - protected DateTimeOffset SetChatBoxText(string? text, TimeSpan displayLength) => ChatBoxInterface.SetText(text, ChatBoxPriority, displayLength); - - protected void SetChatBoxTyping(bool typing) => ChatBoxInterface.SetTyping(typing); - - protected enum ChatBoxSetting - { - ChatBoxDisplay, - ChatBoxFormat, - ChatBoxMode, - ChatBoxTimer, - ChatBoxLength - } - - private enum ChatBoxMode - { - Timed, - Always - } -} diff --git a/VRCOSC.Game/Modules/GameManager.cs b/VRCOSC.Game/Modules/GameManager.cs index 95716edc..075a3110 100644 --- a/VRCOSC.Game/Modules/GameManager.cs +++ b/VRCOSC.Game/Modules/GameManager.cs @@ -9,13 +9,20 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics.Containers; +using osu.Framework.Development; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Framework.Threading; using Valve.VR; +using VRCOSC.Game.ChatBox; using VRCOSC.Game.Config; using VRCOSC.Game.Graphics.Notifications; using VRCOSC.Game.Modules.Avatar; +using VRCOSC.Game.Modules.Manager; +using VRCOSC.Game.Modules.Serialisation; +using VRCOSC.Game.Modules.Sources; using VRCOSC.Game.OpenVR; using VRCOSC.Game.OpenVR.Metadata; using VRCOSC.Game.OSC; @@ -24,7 +31,7 @@ namespace VRCOSC.Game.Modules; -public partial class GameManager : CompositeComponent +public partial class GameManager : Component { private const double openvr_check_interval = 1000; private const double vrchat_process_check_interval = 5000; @@ -45,22 +52,34 @@ public partial class GameManager : CompositeComponent [Resolved(name: "InfoModule")] private Bindable infoModule { get; set; } = null!; + [Resolved] + private Storage storage { get; set; } = null!; + + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved] + private IVRCOSCSecrets secrets { get; set; } = null!; + + [Resolved] + private ChatBoxManager chatBoxManager { get; set; } = null!; + private Bindable autoStartStop = null!; private bool hasAutoStarted; private readonly List oscDataCache = new(); private readonly object oscDataCacheLock = new(); public readonly VRChatOscClient VRChatOscClient = new(); - public readonly ModuleManager ModuleManager = new(); public readonly Bindable State = new(GameManagerState.Stopped); + public IModuleManager ModuleManager = null!; public OSCRouter OSCRouter = null!; public Player Player = null!; public OVRClient OVRClient = null!; - public ChatBoxInterface ChatBoxInterface = null!; + public ChatBoxManager ChatBoxManager => chatBoxManager; public AvatarConfig? AvatarConfig; [BackgroundDependencyLoader] - private void load(Storage storage) + private void load() { autoStartStop = configManager.GetBindable(VRCOSCSetting.AutoStartStop); @@ -75,9 +94,19 @@ private void load(Storage storage) }); OVRHelper.OnError += m => Logger.Log($"[OpenVR] {m}"); - ChatBoxInterface = new ChatBoxInterface(VRChatOscClient, configManager.GetBindable(VRCOSCSetting.ChatBoxTimeSpan)); + setupModules(); - AddInternal(ModuleManager); + chatBoxManager.Load(storage, this); + } + + private void setupModules() + { + ModuleManager = new ModuleManager(); + ModuleManager.AddSource(new InternalModuleSource()); + ModuleManager.AddSource(new ExternalModuleSource(storage)); + ModuleManager.SetSerialiser(new ModuleSerialiser(storage)); + ModuleManager.InjectModuleDependencies(host, this, secrets, new Scheduler(() => ThreadSafety.IsUpdateThread, Clock)); + ModuleManager.Load(); } protected override void Update() @@ -87,13 +116,10 @@ protected override void Update() OVRClient.Update(); handleOscDataCache(); - } - protected override void UpdateAfterChildren() - { - if (State.Value != GameManagerState.Started) return; + ModuleManager.Update(); - ChatBoxInterface.Update(); + ChatBoxManager.Update(); } protected override void LoadComplete() @@ -140,12 +166,15 @@ private void handleOscDataCache() switch (data.ParameterName) { case @"VRCOSC/Controls/ChatBox": - ChatBoxInterface.SendEnabled = (bool)data.ParameterValue; + ChatBoxManager.SendEnabled = (bool)data.ParameterValue; break; } } - ModuleManager.OnParameterReceived(data); + foreach (var module in ModuleManager) + { + module.OnParameterReceived(data); + } }); oscDataCache.Clear(); } @@ -177,13 +206,16 @@ private async Task startAsync() return; } + var moduleEnabled = new Dictionary(); + ModuleManager.ForEach(module => moduleEnabled.Add(module.SerialisedName, module.Enabled.Value)); + State.Value = GameManagerState.Starting; await Task.Delay(startstop_delay); VRChatOscClient.Enable(OscClientFlag.Send); Player.Initialise(); - ChatBoxInterface.Initialise(); + ChatBoxManager.Initialise(VRChatOscClient, configManager.GetBindable(VRCOSCSetting.ChatBoxTimeSpan), moduleEnabled); sendControlValues(); ModuleManager.Start(); VRChatOscClient.Enable(OscClientFlag.Receive); @@ -212,7 +244,7 @@ private async Task stopAsync() await OSCRouter.Disable(); ModuleManager.Stop(); - ChatBoxInterface.Shutdown(); + ChatBoxManager.Shutdown(); Player.ResetAll(); await VRChatOscClient.Disable(OscClientFlag.Send); @@ -283,7 +315,7 @@ private void checkForVRChat() private void sendControlValues() { - VRChatOscClient.SendValue(@$"{VRChatOscConstants.ADDRESS_AVATAR_PARAMETERS_PREFIX}/VRCOSC/Controls/ChatBox", ChatBoxInterface.SendEnabled); + VRChatOscClient.SendValue(@$"{VRChatOscConstants.ADDRESS_AVATAR_PARAMETERS_PREFIX}/VRCOSC/Controls/ChatBox", ChatBoxManager.SendEnabled); } } diff --git a/VRCOSC.Game/Modules/Manager/IModuleManager.cs b/VRCOSC.Game/Modules/Manager/IModuleManager.cs new file mode 100644 index 00000000..7539b3ef --- /dev/null +++ b/VRCOSC.Game/Modules/Manager/IModuleManager.cs @@ -0,0 +1,26 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System.Collections.Generic; +using osu.Framework.Platform; +using osu.Framework.Threading; +using VRCOSC.Game.Modules.Serialisation; +using VRCOSC.Game.Modules.Sources; + +namespace VRCOSC.Game.Modules.Manager; + +public interface IModuleManager : IEnumerable +{ + public void AddSource(IModuleSource source); + public bool RemoveSource(IModuleSource source); + public void SetSerialiser(IModuleSerialiser serialiser); + public void InjectModuleDependencies(GameHost host, GameManager gameManager, IVRCOSCSecrets secrets, Scheduler scheduler); + public void Load(); + public void SaveAll(); + public void Save(Module module); + public void Start(); + public void Update(); + public void Stop(); + public IEnumerable GetEnabledModuleNames(); + public string GetModuleName(string serialisedName); +} diff --git a/VRCOSC.Game/Modules/Manager/ModuleManager.cs b/VRCOSC.Game/Modules/Manager/ModuleManager.cs new file mode 100644 index 00000000..667939a8 --- /dev/null +++ b/VRCOSC.Game/Modules/Manager/ModuleManager.cs @@ -0,0 +1,120 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Lists; +using osu.Framework.Platform; +using osu.Framework.Threading; +using VRCOSC.Game.Modules.Serialisation; +using VRCOSC.Game.Modules.Sources; + +namespace VRCOSC.Game.Modules.Manager; + +public sealed class ModuleManager : IModuleManager +{ + private static TerminalLogger terminal => new("ModuleManager"); + + private readonly List sources = new(); + private readonly SortedList modules = new(); + private IModuleSerialiser? serialiser; + + public Action? OnModuleEnabledChanged; + + public void AddSource(IModuleSource source) => sources.Add(source); + public bool RemoveSource(IModuleSource source) => sources.Remove(source); + public void SetSerialiser(IModuleSerialiser serialiser) => this.serialiser = serialiser; + + private GameHost host = null!; + private GameManager gameManager = null!; + private IVRCOSCSecrets secrets = null!; + private Scheduler scheduler = null!; + + public void InjectModuleDependencies(GameHost host, GameManager gameManager, IVRCOSCSecrets secrets, Scheduler scheduler) + { + this.host = host; + this.gameManager = gameManager; + this.secrets = secrets; + this.scheduler = scheduler; + } + + public void Load() + { + modules.Clear(); + + sources.ForEach(source => + { + foreach (var type in source.Load()) + { + var module = (Module)Activator.CreateInstance(type)!; + module.InjectDependencies(host, gameManager, secrets, scheduler); + modules.Add(module); + } + }); + + foreach (var module in this) + { + module.Load(); + serialiser?.Deserialise(module); + + module.Enabled.BindValueChanged(_ => + { + OnModuleEnabledChanged?.Invoke(); + serialiser?.Serialise(module); + }); + } + } + + public void SaveAll() + { + foreach (var module in this) + { + serialiser?.Serialise(module); + } + } + + public void Save(Module module) + { + serialiser?.Serialise(module); + } + + public void Start() + { + if (modules.All(module => !module.Enabled.Value)) + terminal.Log("You have no modules selected!\nSelect some modules to begin using VRCOSC"); + + foreach (var module in modules) + { + module.Start(); + } + } + + public void Update() + { + scheduler.Update(); + + foreach (var module in modules) + { + module.FrameUpdate(); + } + } + + public void Stop() + { + scheduler.CancelDelayedTasks(); + + foreach (var module in modules) + { + module.Stop(); + } + } + + public IEnumerable GetEnabledModuleNames() => modules.Where(module => module.Enabled.Value).Select(module => module.SerialisedName); + + public string GetModuleName(string serialisedName) => modules.Single(module => module.SerialisedName == serialisedName).Title; + + public IEnumerator GetEnumerator() => modules.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/VRCOSC.Game/Modules/Module.cs b/VRCOSC.Game/Modules/Module.cs index 6e702396..786eac42 100644 --- a/VRCOSC.Game/Modules/Module.cs +++ b/VRCOSC.Game/Modules/Module.cs @@ -4,12 +4,12 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics; using osu.Framework.Platform; +using osu.Framework.Threading; +using VRCOSC.Game.ChatBox; using VRCOSC.Game.Modules.Avatar; using VRCOSC.Game.OpenVR; using VRCOSC.Game.OSC.VRChat; @@ -19,26 +19,20 @@ namespace VRCOSC.Game.Modules; -public abstract partial class Module : Component, IComparable +public abstract class Module : IComparable { - [Resolved] - private GameHost Host { get; set; } = null!; + private GameHost Host = null!; + private GameManager GameManager = null!; + protected IVRCOSCSecrets Secrets { get; private set; } = null!; + private Scheduler Scheduler = null!; - [Resolved] - private GameManager GameManager { get; set; } = null!; - - [Resolved] - private IVRCOSCSecrets secrets { get; set; } = null!; - - private Storage Storage = null!; private TerminalLogger Terminal = null!; protected Player Player => GameManager.Player; protected OVRClient OVRClient => GameManager.OVRClient; protected VRChatOscClient OscClient => GameManager.VRChatOscClient; - protected ChatBoxInterface ChatBoxInterface => GameManager.ChatBoxInterface; + protected ChatBoxManager ChatBoxManager => GameManager.ChatBoxManager; protected Bindable State = new(ModuleState.Stopped); - protected IVRCOSCSecrets Secrets => secrets; protected AvatarConfig? AvatarConfig => GameManager.AvatarConfig; internal readonly BindableBool Enabled = new(); @@ -56,7 +50,9 @@ public abstract partial class Module : Component, IComparable private bool IsEnabled => Enabled.Value; private bool ShouldUpdate => DeltaUpdate != TimeSpan.MaxValue; - private string FileName => @$"{GetType().Name}.ini"; + internal string Name => GetType().Name; + internal string SerialisedName => Name.ToLowerInvariant(); + internal string FileName => @$"{Name}.ini"; protected bool IsStarting => State.Value == ModuleState.Starting; protected bool HasStarted => State.Value == ModuleState.Started; @@ -66,21 +62,21 @@ public abstract partial class Module : Component, IComparable internal bool HasSettings => Settings.Any(); internal bool HasParameters => Parameters.Any(); - [BackgroundDependencyLoader] - internal void load(Storage storage) + public void InjectDependencies(GameHost host, GameManager gameManager, IVRCOSCSecrets secrets, Scheduler scheduler) + { + Host = host; + GameManager = gameManager; + Secrets = secrets; + Scheduler = scheduler; + } + + public void Load() { - Storage = storage.GetStorageForDirectory("modules"); Terminal = new TerminalLogger(Title); CreateAttributes(); Parameters.ForEach(pair => ParametersLookup.Add(pair.Key.ToLookup(), pair.Key)); - - performLoad(); - } - - protected override void LoadComplete() - { State.ValueChanged += _ => Log(State.Value.ToString()); } @@ -146,14 +142,14 @@ internal void Start() if (ShouldUpdateImmediately) OnModuleUpdate(); } + internal void FrameUpdate() => OnFrameUpdate(); + internal void Stop() { if (!IsEnabled) return; State.Value = ModuleState.Stopping; - Scheduler.CancelDelayedTasks(); - OnModuleStop(); State.Value = ModuleState.Stopped; @@ -161,6 +157,7 @@ internal void Stop() protected virtual void OnModuleStart() { } protected virtual void OnModuleUpdate() { } + protected virtual void OnFrameUpdate() { } protected virtual void OnModuleStop() { } protected virtual void OnAvatarChange() { } diff --git a/VRCOSC.Game/Modules/ModuleManager.cs b/VRCOSC.Game/Modules/ModuleManager.cs deleted file mode 100644 index f6c14f1c..00000000 --- a/VRCOSC.Game/Modules/ModuleManager.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. -// See the LICENSE file in the repository root for full license text. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using osu.Framework.Allocation; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics.Containers; -using osu.Framework.Lists; -using osu.Framework.Logging; -using osu.Framework.Platform; -using VRCOSC.Game.OSC.VRChat; - -namespace VRCOSC.Game.Modules; - -public sealed partial class ModuleManager : CompositeComponent, IEnumerable -{ - private readonly TerminalLogger terminal = new(nameof(ModuleManager)); - - private readonly SortedList tempModuleList = new(); - - [Resolved] - private Storage storage { get; set; } = null!; - - [BackgroundDependencyLoader] - private void load() - { - loadInternalModules(); - loadExternalModules(); - AddRangeInternal(tempModuleList); - } - - private void loadInternalModules() - { - var assemblyPath = Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory, "*.dll", SearchOption.AllDirectories) - .FirstOrDefault(fileName => fileName.Contains("VRCOSC.Modules")); - - if (string.IsNullOrEmpty(assemblyPath)) - { - Logger.Log("Could not find internal module assembly"); - return; - } - - loadModuleAssembly(assemblyPath); - } - - private void loadExternalModules() - { - var moduleDirectoryPath = storage.GetStorageForDirectory("custom").GetFullPath(string.Empty, true); - Directory.GetFiles(moduleDirectoryPath, "*.dll", SearchOption.AllDirectories) - .ForEach(loadModuleAssembly); - } - - private void loadModuleAssembly(string assemblyPath) - { - Assembly.LoadFile(assemblyPath).GetTypes() - .Where(type => type.IsSubclassOf(typeof(Module)) && !type.IsAbstract) - .Select(type => (Module)Activator.CreateInstance(type)!) - .ForEach(module => tempModuleList.Add(module)); - } - - public void Start() - { - if (this.All(module => !module.Enabled.Value)) - { - terminal.Log("You have no modules selected!\nSelect some modules to begin using VRCOSC"); - return; - } - - foreach (var module in this) - { - module.Start(); - } - } - - public void Stop() - { - foreach (var module in this) - { - module.Stop(); - } - } - - public void OnParameterReceived(VRChatOscData data) - { - foreach (var module in this) - { - module.OnParameterReceived(data); - } - } - - public IEnumerator GetEnumerator() => InternalChildren.Select(child => (Module)child).GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); -} diff --git a/VRCOSC.Game/Modules/Serialisation/IModuleSerialiser.cs b/VRCOSC.Game/Modules/Serialisation/IModuleSerialiser.cs new file mode 100644 index 00000000..7706ac47 --- /dev/null +++ b/VRCOSC.Game/Modules/Serialisation/IModuleSerialiser.cs @@ -0,0 +1,10 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +namespace VRCOSC.Game.Modules.Serialisation; + +public interface IModuleSerialiser +{ + public void Deserialise(Module module); + public void Serialise(Module module); +} diff --git a/VRCOSC.Game/Modules/Module_IO.cs b/VRCOSC.Game/Modules/Serialisation/ModuleSerialiser.cs similarity index 78% rename from VRCOSC.Game/Modules/Module_IO.cs rename to VRCOSC.Game/Modules/Serialisation/ModuleSerialiser.cs index a53081aa..c0ae3b60 100644 --- a/VRCOSC.Game/Modules/Module_IO.cs +++ b/VRCOSC.Game/Modules/Serialisation/ModuleSerialiser.cs @@ -7,16 +7,23 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Logging; +using osu.Framework.Platform; -namespace VRCOSC.Game.Modules; +namespace VRCOSC.Game.Modules.Serialisation; -public partial class Module +public class ModuleSerialiser : IModuleSerialiser { - #region Loading + private const string directory_name = "modules"; + private readonly Storage storage; - private void performLoad() + public ModuleSerialiser(Storage storage) { - using (var stream = Storage.GetStream(FileName)) + this.storage = storage.GetStorageForDirectory(directory_name); + } + + public void Deserialise(Module module) + { + using (var stream = storage.GetStream(module.FileName)) { if (stream is not null) { @@ -27,25 +34,25 @@ private void performLoad() switch (line) { case "#InternalSettings": - performInternalSettingsLoad(reader); + performInternalSettingsLoad(reader, module); break; case "#Settings": - performSettingsLoad(reader); + performSettingsLoad(reader, module); break; case "#Parameters": - performParametersLoad(reader); + performParametersLoad(reader, module); break; } } } } - executeAfterLoad(); + Serialise(module); } - private void performInternalSettingsLoad(TextReader reader) + private static void performInternalSettingsLoad(TextReader reader, Module module) { while (reader.ReadLine() is { } line) { @@ -58,13 +65,13 @@ private void performInternalSettingsLoad(TextReader reader) switch (lookup) { case "enabled": - Enabled.Value = bool.Parse(value); + module.Enabled.Value = bool.Parse(value); break; } } } - private void performSettingsLoad(TextReader reader) + private static void performSettingsLoad(TextReader reader, Module module) { while (reader.ReadLine() is { } line) { @@ -80,9 +87,9 @@ private void performSettingsLoad(TextReader reader) var lookup = lookupStr; if (lookupStr.Contains('#')) lookup = lookupStr.Split(new[] { '#' }, 2)[0]; - if (!Settings.ContainsKey(lookup)) continue; + if (!module.Settings.ContainsKey(lookup)) continue; - var setting = Settings[lookup]; + var setting = module.Settings[lookup]; switch (setting) { @@ -157,7 +164,7 @@ private void performSettingsLoad(TextReader reader) } } - private void performParametersLoad(TextReader reader) + private static void performParametersLoad(TextReader reader, Module module) { while (reader.ReadLine() is { } line) { @@ -167,19 +174,13 @@ private void performParametersLoad(TextReader reader) var lookup = lineSplit[0]; var value = lineSplit[1]; - if (!ParametersLookup.ContainsKey(lookup)) continue; + if (!module.ParametersLookup.ContainsKey(lookup)) continue; - var parameter = Parameters[ParametersLookup[lookup]]; + var parameter = module.Parameters[module.ParametersLookup[lookup]]; parameter.Attribute.Value = value; } } - private void executeAfterLoad() - { - performSave(); - Enabled.BindValueChanged(_ => performSave()); - } - private static Type? enumNameToType(string enumName) { Type? returnType = null; @@ -201,40 +202,31 @@ private void executeAfterLoad() return returnType; } - #endregion - - #region Saving - - public void Save() - { - performSave(); - } - - private void performSave() + public void Serialise(Module module) { - using var stream = Storage.CreateFileSafely(FileName); + using var stream = storage.CreateFileSafely(module.FileName); using var writer = new StreamWriter(stream); - performInternalSettingsSave(writer); - performSettingsSave(writer); - performParametersSave(writer); + performInternalSettingsSave(writer, module); + performSettingsSave(writer, module); + performParametersSave(writer, module); } - private void performInternalSettingsSave(TextWriter writer) + private static void performInternalSettingsSave(TextWriter writer, Module module) { writer.WriteLine(@"#InternalSettings"); - writer.WriteLine(@"{0}={1}", "enabled", Enabled.Value.ToString()); + writer.WriteLine(@"{0}={1}", "enabled", module.Enabled.Value.ToString()); writer.WriteLine(@"#End"); } - private void performSettingsSave(TextWriter writer) + private static void performSettingsSave(TextWriter writer, Module module) { - var areAllDefault = Settings.All(pair => pair.Value.IsDefault()); + var areAllDefault = module.Settings.All(pair => pair.Value.IsDefault()); if (areAllDefault) return; writer.WriteLine(@"#Settings"); - foreach (var (lookup, moduleAttributeData) in Settings) + foreach (var (lookup, moduleAttributeData) in module.Settings) { if (moduleAttributeData.IsDefault()) continue; @@ -286,14 +278,14 @@ private void performSettingsSave(TextWriter writer) writer.WriteLine(@"#End"); } - private void performParametersSave(TextWriter writer) + private static void performParametersSave(TextWriter writer, Module module) { - var areAllDefault = Parameters.All(pair => pair.Value.IsDefault()); + var areAllDefault = module.Parameters.All(pair => pair.Value.IsDefault()); if (areAllDefault) return; writer.WriteLine(@"#Parameters"); - foreach (var (lookup, parameterAttribute) in Parameters) + foreach (var (lookup, parameterAttribute) in module.Parameters) { if (parameterAttribute.IsDefault()) continue; @@ -303,6 +295,4 @@ private void performParametersSave(TextWriter writer) writer.WriteLine(@"#End"); } - - #endregion } diff --git a/VRCOSC.Game/Modules/Sources/DLLModuleSource.cs b/VRCOSC.Game/Modules/Sources/DLLModuleSource.cs new file mode 100644 index 00000000..1a7a75b8 --- /dev/null +++ b/VRCOSC.Game/Modules/Sources/DLLModuleSource.cs @@ -0,0 +1,19 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace VRCOSC.Game.Modules.Sources; + +public abstract class DLLModuleSource : IModuleSource +{ + protected IEnumerable LoadModulesFromDLL(string dllPath) + { + return Assembly.LoadFile(dllPath).GetTypes().Where(type => type.IsSubclassOf(typeof(Module)) && !type.IsAbstract); + } + + public abstract IEnumerable Load(); +} diff --git a/VRCOSC.Game/Modules/Sources/ExternalModuleSource.cs b/VRCOSC.Game/Modules/Sources/ExternalModuleSource.cs new file mode 100644 index 00000000..4b5cf831 --- /dev/null +++ b/VRCOSC.Game/Modules/Sources/ExternalModuleSource.cs @@ -0,0 +1,31 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System; +using System.Collections.Generic; +using System.IO; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Platform; + +namespace VRCOSC.Game.Modules.Sources; + +public class ExternalModuleSource : DLLModuleSource +{ + private const string custom_directory = "custom"; + + private readonly Storage storage; + + public ExternalModuleSource(Storage storage) + { + this.storage = storage; + } + + public override IEnumerable Load() + { + var moduleDirectoryPath = storage.GetStorageForDirectory(custom_directory).GetFullPath(string.Empty, true); + + var moduleList = new List(); + Directory.GetFiles(moduleDirectoryPath, "*.dll", SearchOption.AllDirectories).ForEach(dllPath => moduleList.AddRange(LoadModulesFromDLL(dllPath))); + return moduleList; + } +} diff --git a/VRCOSC.Game/Modules/Sources/IModuleSource.cs b/VRCOSC.Game/Modules/Sources/IModuleSource.cs new file mode 100644 index 00000000..e48c8618 --- /dev/null +++ b/VRCOSC.Game/Modules/Sources/IModuleSource.cs @@ -0,0 +1,12 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System; +using System.Collections.Generic; + +namespace VRCOSC.Game.Modules.Sources; + +public interface IModuleSource +{ + public IEnumerable Load(); +} diff --git a/VRCOSC.Game/Modules/Sources/InternalModuleSource.cs b/VRCOSC.Game/Modules/Sources/InternalModuleSource.cs new file mode 100644 index 00000000..954b0686 --- /dev/null +++ b/VRCOSC.Game/Modules/Sources/InternalModuleSource.cs @@ -0,0 +1,26 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using osu.Framework.Logging; + +namespace VRCOSC.Game.Modules.Sources; + +public class InternalModuleSource : DLLModuleSource +{ + public override IEnumerable Load() + { + var dllPath = Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory, "*.dll", SearchOption.AllDirectories).FirstOrDefault(fileName => fileName.Contains("VRCOSC.Modules")); + + if (string.IsNullOrEmpty(dllPath)) + { + Logger.Log("Could not find internal module assembly"); + return new List(); + } + + return LoadModulesFromDLL(dllPath); + } +} diff --git a/VRCOSC.Game/Providers/Media/WindowsMediaProvider.cs b/VRCOSC.Game/Providers/Media/WindowsMediaProvider.cs index e59ae99c..36b16ee0 100644 --- a/VRCOSC.Game/Providers/Media/WindowsMediaProvider.cs +++ b/VRCOSC.Game/Providers/Media/WindowsMediaProvider.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; +using System.Threading.Tasks; using Windows.Media; using Windows.Media.Control; using osu.Framework.Extensions.IEnumerableExtensions; @@ -17,21 +18,26 @@ public class WindowsMediaProvider private readonly List sessions = new(); private GlobalSystemMediaTransportControlsSessionManager? sessionManager; - public GlobalSystemMediaTransportControlsSession? Controller => sessionManager!.GetCurrentSession(); + public GlobalSystemMediaTransportControlsSession? Controller => sessionManager?.GetCurrentSession(); public Action? OnPlaybackStateUpdate; + public Action? OnTrackChange; public MediaState State { get; private set; } = null!; - public async void Hook() + public async Task Hook() { State = new MediaState(); sessionManager ??= await GlobalSystemMediaTransportControlsSessionManager.RequestAsync(); - sessionManager!.CurrentSessionChanged += onCurrentSessionChanged; + if (sessionManager is null) return false; + + sessionManager.CurrentSessionChanged += onCurrentSessionChanged; sessionManager.SessionsChanged += sessionsChanged; sessionsChanged(null, null); onCurrentSessionChanged(null, null); + + return true; } public void UnHook() @@ -61,6 +67,12 @@ private void onAnyMediaPropertyChanged(GlobalSystemMediaTransportControlsSession State.Title = args.Title; State.Artist = args.Artist; + State.TrackNumber = args.TrackNumber; + State.AlbumTitle = args.AlbumTitle; + State.AlbumArtist = args.AlbumArtist; + State.AlbumTrackCount = args.AlbumTrackCount; + + OnTrackChange?.Invoke(); } private void onAnyTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession session, GlobalSystemMediaTransportControlsSessionTimelineProperties args) @@ -116,6 +128,10 @@ public class MediaState public string? ProcessId; public string Title = string.Empty; public string Artist = string.Empty; + public int TrackNumber; + public string AlbumTitle = string.Empty; + public string AlbumArtist = string.Empty; + public int AlbumTrackCount; public MediaPlaybackAutoRepeatMode RepeatMode; public bool IsShuffle; public GlobalSystemMediaTransportControlsSessionPlaybackStatus Status; diff --git a/VRCOSC.Game/VRCOSC.Game.csproj b/VRCOSC.Game/VRCOSC.Game.csproj index a69cf84d..b173e30a 100644 --- a/VRCOSC.Game/VRCOSC.Game.csproj +++ b/VRCOSC.Game/VRCOSC.Game.csproj @@ -1,10 +1,10 @@ - net6.0-windows10.0.22000.0 + net6.0-windows10.0.22621.0 enable 11 VolcanicArts.VRCOSC.SDK - 2023.306.1 + 2023.423.0 VRCOSC SDK VolcanicArts SDK for creating custom modules with VRCOSC @@ -17,10 +17,10 @@ - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/VRCOSC.Game/VRCOSCGame.cs b/VRCOSC.Game/VRCOSCGame.cs index a3331b62..159a633f 100644 --- a/VRCOSC.Game/VRCOSCGame.cs +++ b/VRCOSC.Game/VRCOSCGame.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; +using VRCOSC.Game.ChatBox; using VRCOSC.Game.Config; using VRCOSC.Game.Graphics; using VRCOSC.Game.Graphics.Notifications; @@ -47,7 +48,10 @@ public abstract partial class VRCOSCGame : VRCOSCGameBase private Bindable typeFilter = new(); [Cached] - protected GameManager GameManager = new(); + public GameManager GameManager = new(); + + [Cached] + public ChatBoxManager ChatBoxManager = new(); private NotificationContainer notificationContainer = null!; private VRCOSCUpdateManager updateManager = null!; @@ -186,6 +190,7 @@ private void performExit() editingModule.Value = null; infoModule.Value = null; routerManager.SaveData(); + ChatBoxManager.Save(); Exit(); } diff --git a/VRCOSC.Modules/AFK/AFKModule.cs b/VRCOSC.Modules/AFK/AFKModule.cs new file mode 100644 index 00000000..c415fa91 --- /dev/null +++ b/VRCOSC.Modules/AFK/AFKModule.cs @@ -0,0 +1,68 @@ +// Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. +// See the LICENSE file in the repository root for full license text. + +using VRCOSC.Game.Modules.ChatBox; + +namespace VRCOSC.Modules.AFK; + +public class AFKModule : ChatBoxModule +{ + public override string Title => "AFK Display"; + public override string Description => "Display text and time since going AFK"; + public override string Author => "VolcanicArts"; + public override ModuleType Type => ModuleType.General; + protected override TimeSpan DeltaUpdate => TimeSpan.FromSeconds(1.5f); + + private DateTime? afkBegan; + + protected override void CreateAttributes() + { + CreateVariable(AFKModuleVariable.Duration, "Duration", "duration"); + CreateVariable(AFKModuleVariable.Since, "Since", "since"); + + CreateState(AFKModuleState.AFK, "AFK", $"AFK for {GetVariableFormat(AFKModuleVariable.Duration)}"); + CreateState(AFKModuleState.NotAFK, "Not AFK", string.Empty); + } + + protected override void OnModuleStart() + { + afkBegan = null; + + ChangeStateTo(AFKModuleState.NotAFK); + } + + protected override void OnModuleUpdate() + { + if (Player.AFK is null) + { + ChangeStateTo(AFKModuleState.NotAFK); + return; + } + + if (Player.AFK.Value && afkBegan is null) + { + afkBegan = DateTime.Now; + } + + if (!Player.AFK.Value && afkBegan is not null) + { + afkBegan = null; + } + + SetVariableValue(AFKModuleVariable.Duration, afkBegan is null ? null : (DateTime.Now - afkBegan.Value).ToString(@"hh\:mm\:ss")); + SetVariableValue(AFKModuleVariable.Since, afkBegan?.ToString(@"hh\:mm")); + ChangeStateTo(afkBegan is null ? AFKModuleState.NotAFK : AFKModuleState.AFK); + } + + private enum AFKModuleVariable + { + Duration, + Since + } + + private enum AFKModuleState + { + AFK, + NotAFK + } +} diff --git a/VRCOSC.Modules/ChatBoxText/ChatBoxTextModule.cs b/VRCOSC.Modules/ChatBoxText/ChatBoxTextModule.cs index 3b44e366..75ccc0fa 100644 --- a/VRCOSC.Modules/ChatBoxText/ChatBoxTextModule.cs +++ b/VRCOSC.Modules/ChatBoxText/ChatBoxTextModule.cs @@ -1,58 +1,61 @@ // Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. // See the LICENSE file in the repository root for full license text. -using VRCOSC.Game.Modules; +using VRCOSC.Game.Modules.ChatBox; namespace VRCOSC.Modules.ChatBoxText; -public partial class ChatBoxTextModule : ChatBoxModule +public class ChatBoxTextModule : ChatBoxModule { public override string Title => "ChatBox Text"; public override string Description => "Display custom text in the ChatBox"; public override string Author => "VolcanicArts"; public override ModuleType Type => ModuleType.General; protected override TimeSpan DeltaUpdate => TimeSpan.FromSeconds(1.5f); - protected override bool ShouldUpdateImmediately => false; - protected override int ChatBoxPriority => 1; private int index; - protected override string GetChatBoxText() + protected override void CreateAttributes() { - var text = GetSetting(ChatBoxTextSetting.ChatBoxText); - - if (!GetSetting(ChatBoxTextSetting.Animate)) return text; - - var splitter = GetSetting(ChatBoxTextSetting.Splitter); - - var tickerText = $"{text}{splitter}{text}"; - var maxLength = Math.Min(GetSetting(ChatBoxTextSetting.MaxLength), text.Length); - - if (index > text.Length + splitter.Length - 1) index = 0; + CreateSetting(ChatBoxTextSetting.ChatBoxText, "ChatBox Text", "What text should be displayed in the ChatBox?", string.Empty); + CreateSetting(ChatBoxTextSetting.Animate, "Animate", "Should the text animate like a ticker tape?", false); + CreateSetting(ChatBoxTextSetting.ScrollSpeed, "Scroll Speed", "How fast should the text scroll? Measured in characters per update.", 1, () => GetSetting(ChatBoxTextSetting.Animate)); + CreateSetting(ChatBoxTextSetting.Splitter, "Splitter", "The splitter that goes between loops of the text", " | ", () => GetSetting(ChatBoxTextSetting.Animate)); + CreateSetting(ChatBoxTextSetting.MaxLength, "Max Length", "The maximum length to show at one time when animating", 16, () => GetSetting(ChatBoxTextSetting.Animate)); - tickerText = tickerText[index..(maxLength + index)]; + CreateVariable(ChatBoxTextVariable.Text, "Text", "text"); - return tickerText; + CreateState(ChatBoxTextState.Default, "Default", $"{GetVariableFormat(ChatBoxTextVariable.Text)}"); } protected override void OnModuleStart() { - index = 0; + index = -1; + ChangeStateTo(ChatBoxTextState.Default); } protected override void OnModuleUpdate() { index += GetSetting(ChatBoxTextSetting.ScrollSpeed); - } - protected override void CreateAttributes() - { - CreateSetting(ChatBoxTextSetting.ChatBoxText, "ChatBox Text", "What text should be displayed in the ChatBox?", string.Empty); - CreateSetting(ChatBoxTextSetting.Animate, "Animate", "Should the text animate like a ticker tape?", false); - CreateSetting(ChatBoxTextSetting.ScrollSpeed, "Scroll Speed", "How fast should the text scroll? Measured in characters per update.", 1, () => GetSetting(ChatBoxTextSetting.Animate)); - CreateSetting(ChatBoxTextSetting.Splitter, "Splitter", "The splitter that goes between loops of the text", " | ", () => GetSetting(ChatBoxTextSetting.Animate)); - CreateSetting(ChatBoxTextSetting.MaxLength, "Max Length", "The maximum length to show at one time when animating", 16, () => GetSetting(ChatBoxTextSetting.Animate)); - base.CreateAttributes(); + var text = GetSetting(ChatBoxTextSetting.ChatBoxText); + + if (!GetSetting(ChatBoxTextSetting.Animate)) + { + SetVariableValue(ChatBoxTextVariable.Text, text); + return; + } + + var splitter = GetSetting(ChatBoxTextSetting.Splitter); + + var tickerText = $"{text}{splitter}{text}"; + var maxLength = Math.Min(GetSetting(ChatBoxTextSetting.MaxLength), text.Length); + + if (index > text.Length + splitter.Length - 1) index = 0; + + tickerText = tickerText[index..(maxLength + index)]; + + SetVariableValue(ChatBoxTextVariable.Text, tickerText); } private enum ChatBoxTextSetting @@ -63,4 +66,14 @@ private enum ChatBoxTextSetting Splitter, MaxLength } + + private enum ChatBoxTextState + { + Default + } + + private enum ChatBoxTextVariable + { + Text + } } diff --git a/VRCOSC.Modules/Clock/ClockModule.cs b/VRCOSC.Modules/Clock/ClockModule.cs index b8402bd3..be5a45f4 100644 --- a/VRCOSC.Modules/Clock/ClockModule.cs +++ b/VRCOSC.Modules/Clock/ClockModule.cs @@ -2,11 +2,12 @@ // See the LICENSE file in the repository root for full license text. using VRCOSC.Game.Modules; +using VRCOSC.Game.Modules.ChatBox; using VRCOSC.Game.OSC.VRChat; namespace VRCOSC.Modules.Clock; -public sealed partial class ClockModule : ChatBoxModule +public sealed class ClockModule : ChatBoxModule { public override string Title => "Clock"; public override string Description => "Sends your local time as hours, minutes, and seconds"; @@ -15,9 +16,6 @@ public sealed partial class ClockModule : ChatBoxModule public override ModuleType Type => ModuleType.General; protected override TimeSpan DeltaUpdate => GetSetting(ClockSetting.SmoothSecond) ? VRChatOscConstants.UPDATE_TIME_SPAN : TimeSpan.FromSeconds(1); - protected override string DefaultChatBoxFormat => "Local Time %h%:%m%%period%"; - protected override IEnumerable ChatBoxFormatValues => new[] { "%h%", "%m%", "%s%", "%period%" }; - private DateTime time; protected override void CreateAttributes() @@ -28,22 +26,21 @@ protected override void CreateAttributes() CreateSetting(ClockSetting.Mode, "Mode", "If the clock should be in 12 hour or 24 hour", ClockMode.Twelve); CreateSetting(ClockSetting.Timezone, "Timezone", "The timezone the clock should follow", ClockTimeZone.Local); - base.CreateAttributes(); - CreateParameter(ClockParameter.Hours, ParameterMode.Write, "VRCOSC/Clock/Hours", "Hours", "The current hour normalised"); CreateParameter(ClockParameter.Minutes, ParameterMode.Write, "VRCOSC/Clock/Minutes", "Minutes", "The current minute normalised"); CreateParameter(ClockParameter.Seconds, ParameterMode.Write, "VRCOSC/Clock/Seconds", "Seconds", "The current second normalised"); + + CreateVariable(ClockVariable.Hours, "Hours", "h"); + CreateVariable(ClockVariable.Minutes, "Minutes", "m"); + CreateVariable(ClockVariable.Seconds, "Seconds", "s"); + CreateVariable(ClockVariable.Period, "AM/PM", "period"); + + CreateState(ClockState.Default, "Default", $"Local Time {GetVariableFormat(ClockVariable.Hours)}:{GetVariableFormat(ClockVariable.Minutes)}{GetVariableFormat(ClockVariable.Period)}"); } - protected override string? GetChatBoxText() + protected override void OnModuleStart() { - var chatBoxTime = timezoneToTime(GetSetting(ClockSetting.Timezone)); - var textHour = GetSetting(ClockSetting.Mode) == ClockMode.Twelve ? (chatBoxTime.Hour % 12).ToString("00") : chatBoxTime.Hour.ToString("00"); - return GetSetting(ChatBoxSetting.ChatBoxFormat) - .Replace("%h%", textHour) - .Replace("%m%", chatBoxTime.Minute.ToString("00")) - .Replace("%s%", chatBoxTime.Second.ToString("00")) - .Replace("%period%", chatBoxTime.Hour >= 12 ? "pm" : "am"); + ChangeStateTo(ClockState.Default); } protected override void OnModuleUpdate() @@ -62,6 +59,16 @@ protected override void OnModuleUpdate() SendParameter(ClockParameter.Hours, hourNormalised); SendParameter(ClockParameter.Minutes, minuteNormalised); SendParameter(ClockParameter.Seconds, secondNormalised); + + var hourText = GetSetting(ClockSetting.Mode) == ClockMode.Twelve ? (time.Hour % 12).ToString("00") : time.Hour.ToString("00"); + var minuteText = time.Minute.ToString("00"); + var secondText = time.Second.ToString("00"); + var periodText = time.Hour >= 12 ? "pm" : "am"; + + SetVariableValue(ClockVariable.Hours, hourText); + SetVariableValue(ClockVariable.Minutes, minuteText); + SetVariableValue(ClockVariable.Seconds, secondText); + SetVariableValue(ClockVariable.Period, periodText); } private static float getSmoothedSeconds(DateTime time) => time.Second + time.Millisecond / 1000f; @@ -99,6 +106,19 @@ private enum ClockSetting Mode } + private enum ClockState + { + Default + } + + private enum ClockVariable + { + Hours, + Minutes, + Seconds, + Period + } + private enum ClockMode { Twelve, diff --git a/VRCOSC.Modules/Discord/DiscordModule.cs b/VRCOSC.Modules/Discord/DiscordModule.cs index 11b38f6c..783ebeba 100644 --- a/VRCOSC.Modules/Discord/DiscordModule.cs +++ b/VRCOSC.Modules/Discord/DiscordModule.cs @@ -6,7 +6,7 @@ namespace VRCOSC.Modules.Discord; -public sealed partial class DiscordModule : IntegrationModule +public sealed class DiscordModule : IntegrationModule { public override string Title => "Discord"; public override string Description => "Integration with the Discord desktop app"; diff --git a/VRCOSC.Modules/FaceTracking/SRanipalModule.cs b/VRCOSC.Modules/FaceTracking/SRanipalModule.cs index 13932f4b..d0aec349 100644 --- a/VRCOSC.Modules/FaceTracking/SRanipalModule.cs +++ b/VRCOSC.Modules/FaceTracking/SRanipalModule.cs @@ -10,7 +10,7 @@ namespace VRCOSC.Modules.FaceTracking; -public partial class SRanipalModule : Module +public class SRanipalModule : Module { public override string Title => "SRanipal"; public override string Description => "Hooks into SRanipal and sends face tracking data to VRChat. Interchangeable with VRCFaceTracking"; diff --git a/VRCOSC.Modules/HardwareStats/HardwareStatsModule.cs b/VRCOSC.Modules/HardwareStats/HardwareStatsModule.cs index 6ed485d8..d8310816 100644 --- a/VRCOSC.Modules/HardwareStats/HardwareStatsModule.cs +++ b/VRCOSC.Modules/HardwareStats/HardwareStatsModule.cs @@ -2,11 +2,12 @@ // See the LICENSE file in the repository root for full license text. using VRCOSC.Game.Modules; +using VRCOSC.Game.Modules.ChatBox; using VRCOSC.Game.Providers.Hardware; namespace VRCOSC.Modules.HardwareStats; -public sealed partial class HardwareStatsModule : ChatBoxModule +public sealed class HardwareStatsModule : ChatBoxModule { public override string Title => "Hardware Stats"; public override string Description => "Sends hardware stats and displays them in the ChatBox"; @@ -14,11 +15,6 @@ public sealed partial class HardwareStatsModule : ChatBoxModule public override ModuleType Type => ModuleType.General; protected override TimeSpan DeltaUpdate => TimeSpan.FromSeconds(0.5); - protected override string DefaultChatBoxFormat => @"CPU: $cpuusage$% | GPU: $gpuusage$% RAM: $ramused$GB/$ramtotal$GB"; - - protected override IEnumerable ChatBoxFormatValues => new[] - { @"$cpuusage$ (%)", @"$gpuusage$ (%)", @"$ramusage$ (%)", @"$cputemp$ (C)", @"$gputemp$ (C)", @"$ramtotal$ (GB)", @"$ramused$ (GB)", @"$ramavailable$ (GB)", @"$vramused$ (GB)", @"$vramfree$ (GB)", @"$vramtotal$ (GB)" }; - private HardwareStatsProvider? hardwareStatsProvider; protected override void CreateAttributes() @@ -26,8 +22,6 @@ protected override void CreateAttributes() CreateSetting(HardwareStatsSetting.SelectedCPU, "Selected CPU", "If you have multiple CPUs, enter the (0th based) index of the one you want to track", 0); CreateSetting(HardwareStatsSetting.SelectedGPU, "Selected GPU", "If you have multiple GPUs, enter the (0th based) index of the one you want to track", 0); - base.CreateAttributes(); - CreateParameter(HardwareStatsParameter.CpuUsage, ParameterMode.Write, "VRCOSC/Hardware/CPUUsage", "CPU Usage", "The CPU usage normalised"); CreateParameter(HardwareStatsParameter.GpuUsage, ParameterMode.Write, "VRCOSC/Hardware/GPUUsage", "GPU Usage", "The GPU usage normalised"); CreateParameter(HardwareStatsParameter.RamUsage, ParameterMode.Write, "VRCOSC/Hardware/RAMUsage", "RAM Usage", "The RAM usage normalised"); @@ -36,48 +30,48 @@ protected override void CreateAttributes() CreateParameter(HardwareStatsParameter.RamTotal, ParameterMode.Write, "VRCOSC/Hardware/RAMTotal", "RAM Total", "The total amount of RAM in GB"); CreateParameter(HardwareStatsParameter.RamUsed, ParameterMode.Write, "VRCOSC/Hardware/RAMUsed", "RAM Used", "The used RAM in GB"); CreateParameter(HardwareStatsParameter.RamAvailable, ParameterMode.Write, "VRCOSC/Hardware/RAMAvailable", "RAM Available", "The available RAM in GB"); - CreateParameter(HardwareStatsParameter.VRamFree, ParameterMode.Write, "VRCOSC/Hardware/VRamFree", "VRAM Free", "The amount of free VRAM in GB"); - CreateParameter(HardwareStatsParameter.VRamUsed, ParameterMode.Write, "VRCOSC/Hardware/VRamUsed", "VRAM Used", "The amount of used VRAM in GB"); - CreateParameter(HardwareStatsParameter.VRamTotal, ParameterMode.Write, "VRCOSC/Hardware/VRamTotal", "VRAM Total", "The amount of total VRAM in GB"); - } - - protected override string? GetChatBoxText() - { - if (hardwareStatsProvider is null || !hardwareStatsProvider.CanAcceptQueries) return null; - - try - { - var cpu = hardwareStatsProvider.Cpus[GetSetting(HardwareStatsSetting.SelectedCPU)]; - var gpu = hardwareStatsProvider.Gpus[GetSetting(HardwareStatsSetting.SelectedGPU)]; - var ram = hardwareStatsProvider.Ram; - - return GetSetting(ChatBoxSetting.ChatBoxFormat) - .Replace(@"$cpuusage$", cpu.Usage.ToString("0.00")) - .Replace(@"$gpuusage$", gpu.Usage.ToString("0.00")) - .Replace(@"$ramusage$", ram.Usage.ToString("0.00")) - .Replace(@"$cputemp$", cpu.Temperature.ToString()) - .Replace(@"$gputemp$", gpu.Temperature.ToString()) - .Replace(@"$ramtotal$", ram.Total.ToString("0.0")) - .Replace(@"$ramused$", ram.Used.ToString("0.0")) - .Replace(@"$ramavailable$", ram.Available.ToString("0.0")) - .Replace(@"$vramfree$", (gpu.MemoryFree / 1000f).ToString("0.0")) - .Replace(@"$vramused$", (gpu.MemoryUsed / 1000f).ToString("0.0")) - .Replace(@"$vramtotal$", (gpu.MemoryTotal / 1000f).ToString("0.0")); - } - catch { } - - return null; + CreateParameter(HardwareStatsParameter.VRamFree, ParameterMode.Write, "VRCOSC/Hardware/VRamFree", @"VRAM Free", @"The amount of free VRAM in GB"); + CreateParameter(HardwareStatsParameter.VRamUsed, ParameterMode.Write, "VRCOSC/Hardware/VRamUsed", @"VRAM Used", @"The amount of used VRAM in GB"); + CreateParameter(HardwareStatsParameter.VRamTotal, ParameterMode.Write, "VRCOSC/Hardware/VRamTotal", @"VRAM Total", @"The amount of total VRAM in GB"); + + CreateVariable(HardwareStatsParameter.CpuUsage, @"CPU Usage (%)", @"cpuusage"); + CreateVariable(HardwareStatsParameter.GpuUsage, @"GPU Usage (%)", @"gpuusage"); + CreateVariable(HardwareStatsParameter.RamUsage, @"RAM Usage (%)", @"ramusage"); + CreateVariable(HardwareStatsParameter.CpuTemp, @"CPU Temp (C)", @"cputemp"); + CreateVariable(HardwareStatsParameter.GpuTemp, @"GPU Temp (C)", @"gputemp"); + CreateVariable(HardwareStatsParameter.RamTotal, @"RAM Total (GB)", @"ramtotal"); + CreateVariable(HardwareStatsParameter.RamUsed, @"RAM Used (GB)", @"ramused"); + CreateVariable(HardwareStatsParameter.RamAvailable, @"RAM Available (GB)", @"ramavailable"); + CreateVariable(HardwareStatsParameter.VRamUsed, @"VRAM Used (GB)", @"vramused"); + CreateVariable(HardwareStatsParameter.VRamFree, @"VRAM Free (GB)", @"vramfree"); + CreateVariable(HardwareStatsParameter.VRamTotal, @"VRAM Total (GB)", @"vramtotal"); + + CreateState(HardwareStatsState.Default, "Default", $"CPU: {GetVariableFormat(HardwareStatsParameter.CpuUsage)}% | GPU: {GetVariableFormat(HardwareStatsParameter.GpuUsage)}% RAM: {GetVariableFormat(HardwareStatsParameter.RamUsed)}GB/{GetVariableFormat(HardwareStatsParameter.RamTotal)}GB"); } protected override void OnModuleStart() { - base.OnModuleStart(); hardwareStatsProvider = new HardwareStatsProvider(); + ChangeStateTo(HardwareStatsState.Default); } protected override void OnModuleUpdate() { - if (hardwareStatsProvider is null || !hardwareStatsProvider.CanAcceptQueries) return; + if (hardwareStatsProvider is null || !hardwareStatsProvider.CanAcceptQueries) + { + SetVariableValue(HardwareStatsParameter.CpuUsage, "0"); + SetVariableValue(HardwareStatsParameter.GpuUsage, "0"); + SetVariableValue(HardwareStatsParameter.RamUsage, "0"); + SetVariableValue(HardwareStatsParameter.CpuTemp, "0"); + SetVariableValue(HardwareStatsParameter.GpuTemp, "0"); + SetVariableValue(HardwareStatsParameter.RamTotal, "0"); + SetVariableValue(HardwareStatsParameter.RamUsed, "0"); + SetVariableValue(HardwareStatsParameter.RamAvailable, "0"); + SetVariableValue(HardwareStatsParameter.VRamFree, "0"); + SetVariableValue(HardwareStatsParameter.VRamUsed, "0"); + SetVariableValue(HardwareStatsParameter.VRamTotal, "0"); + return; + } hardwareStatsProvider.Update(); @@ -98,6 +92,18 @@ protected override void OnModuleUpdate() SendParameter(HardwareStatsParameter.VRamFree, gpu.MemoryFree / 1000f); SendParameter(HardwareStatsParameter.VRamUsed, gpu.MemoryUsed / 1000f); SendParameter(HardwareStatsParameter.VRamTotal, gpu.MemoryTotal / 1000f); + + SetVariableValue(HardwareStatsParameter.CpuUsage, cpu.Usage.ToString("0.00")); + SetVariableValue(HardwareStatsParameter.GpuUsage, gpu.Usage.ToString("0.00")); + SetVariableValue(HardwareStatsParameter.RamUsage, ram.Usage.ToString("0.00")); + SetVariableValue(HardwareStatsParameter.CpuTemp, cpu.Temperature.ToString()); + SetVariableValue(HardwareStatsParameter.GpuTemp, gpu.Temperature.ToString()); + SetVariableValue(HardwareStatsParameter.RamTotal, ram.Total.ToString("0.0")); + SetVariableValue(HardwareStatsParameter.RamUsed, ram.Used.ToString("0.0")); + SetVariableValue(HardwareStatsParameter.RamAvailable, ram.Available.ToString("0.0")); + SetVariableValue(HardwareStatsParameter.VRamFree, (gpu.MemoryFree / 1000f).ToString("0.0")); + SetVariableValue(HardwareStatsParameter.VRamUsed, (gpu.MemoryUsed / 1000f).ToString("0.0")); + SetVariableValue(HardwareStatsParameter.VRamTotal, (gpu.MemoryTotal / 1000f).ToString("0.0")); } catch { } } @@ -127,4 +133,9 @@ private enum HardwareStatsParameter VRamUsed, VRamTotal } + + private enum HardwareStatsState + { + Default + } } diff --git a/VRCOSC.Modules/Heartrate/HeartRateModule.cs b/VRCOSC.Modules/Heartrate/HeartRateModule.cs index fa4e88f6..b85647f5 100644 --- a/VRCOSC.Modules/Heartrate/HeartRateModule.cs +++ b/VRCOSC.Modules/Heartrate/HeartRateModule.cs @@ -2,25 +2,23 @@ // See the LICENSE file in the repository root for full license text. using VRCOSC.Game.Modules; +using VRCOSC.Game.Modules.ChatBox; namespace VRCOSC.Modules.Heartrate; -public abstract partial class HeartRateModule : ChatBoxModule +public abstract class HeartRateModule : ChatBoxModule { private static readonly TimeSpan heartrate_timeout = TimeSpan.FromSeconds(10); public override string Author => "VolcanicArts"; public override string Prefab => "VRCOSC-Heartrate"; public override ModuleType Type => ModuleType.Health; - protected override TimeSpan DeltaUpdate => TimeSpan.FromSeconds(2); - protected override int ChatBoxPriority => 1; - - protected override bool DefaultChatBoxDisplay => false; - protected override string DefaultChatBoxFormat => "Heartrate %hr% bpm"; - protected override IEnumerable ChatBoxFormatValues => new[] { "%hr%" }; protected HeartRateProvider? HeartRateProvider; - private int lastHeartrate; + private int currentHeartrate; + private int targetHeartrate; + private TimeSpan targetInterval; + private DateTimeOffset lastIntervalUpdate; private DateTimeOffset lastHeartrateTime; private int connectionCount; @@ -30,22 +28,30 @@ public abstract partial class HeartRateModule : ChatBoxModule protected override void CreateAttributes() { - base.CreateAttributes(); + CreateSetting(HeartrateSetting.Smoothed, @"Smoothed", @"Whether the heartrate value should jump to the correct value, or smoothly climb over a set period", false); + CreateSetting(HeartrateSetting.SmoothingLength, @"Smoothing Length", @"The length of time (in milliseconds) the heartrate value should take to reach the correct value", 1000, () => GetSetting(HeartrateSetting.Smoothed)); + CreateSetting(HeartrateSetting.NormalisedLowerbound, @"Normalised Lowerbound", @"The lower bound BPM the normalised parameter should use", 0); + CreateSetting(HeartrateSetting.NormalisedUpperbound, @"Normalised Upperbound", @"The upper bound BPM the normalised parameter should use", 240); + CreateParameter(HeartrateParameter.Enabled, ParameterMode.Write, "VRCOSC/Heartrate/Enabled", "Enabled", "Whether this module is attempting to emit values"); - CreateParameter(HeartrateParameter.Normalised, ParameterMode.Write, "VRCOSC/Heartrate/Normalised", "Normalised", "The heartrate value normalised to 240bpm"); + CreateParameter(HeartrateParameter.Normalised, ParameterMode.Write, "VRCOSC/Heartrate/Normalised", "Normalised", "The heartrate value normalised to the set bounds"); CreateParameter(HeartrateParameter.Units, ParameterMode.Write, "VRCOSC/Heartrate/Units", "Units", "The units digit 0-9 mapped to a float"); CreateParameter(HeartrateParameter.Tens, ParameterMode.Write, "VRCOSC/Heartrate/Tens", "Tens", "The tens digit 0-9 mapped to a float"); CreateParameter(HeartrateParameter.Hundreds, ParameterMode.Write, "VRCOSC/Heartrate/Hundreds", "Hundreds", "The hundreds digit 0-9 mapped to a float"); - } - protected override string GetChatBoxText() => GetSetting(ChatBoxSetting.ChatBoxFormat).Replace("%hr%", lastHeartrate.ToString()); + CreateVariable(HeartrateVariable.Heartrate, @"Heartrate", @"hr"); + + CreateState(HeartrateState.Default, @"Default", $@"Heartrate {GetVariableFormat(HeartrateVariable.Heartrate)} bpm"); + } protected override void OnModuleStart() { - base.OnModuleStart(); attemptConnection(); - lastHeartrateTime = DateTimeOffset.Now - heartrate_timeout; + lastIntervalUpdate = DateTimeOffset.Now; + currentHeartrate = 0; + targetHeartrate = 0; + ChangeStateTo(HeartrateState.Default); } private void attemptConnection() @@ -60,6 +66,7 @@ private void attemptConnection() HeartRateProvider = CreateHeartRateProvider(); HeartRateProvider.OnHeartRateUpdate += HandleHeartRateUpdate; HeartRateProvider.OnConnected += () => connectionCount = 0; + HeartRateProvider.OnDisconnected += () => { Task.Run(async () => @@ -83,20 +90,41 @@ protected override void OnModuleStop() SendParameter(HeartrateParameter.Enabled, false); } - protected override void OnModuleUpdate() + protected override void OnFrameUpdate() { if (!isReceiving) SendParameter(HeartrateParameter.Enabled, false); + + if (GetSetting(HeartrateSetting.Smoothed)) + { + if (lastIntervalUpdate + targetInterval <= DateTimeOffset.Now) + { + lastIntervalUpdate = DateTimeOffset.Now; + currentHeartrate += Math.Sign(targetHeartrate - currentHeartrate); + } + } + else + { + currentHeartrate = targetHeartrate; + } + + SetVariableValue(HeartrateVariable.Heartrate, currentHeartrate.ToString()); + sendParameters(); } protected virtual void HandleHeartRateUpdate(int heartrate) { - lastHeartrate = heartrate; + targetHeartrate = heartrate; lastHeartrateTime = DateTimeOffset.Now; - - var normalisedHeartRate = heartrate / 240.0f; - var individualValues = toDigitArray(heartrate, 3); + targetInterval = currentHeartrate != targetHeartrate ? TimeSpan.FromTicks(TimeSpan.FromMilliseconds(GetSetting(HeartrateSetting.SmoothingLength)).Ticks / Math.Abs(currentHeartrate - targetHeartrate)) : TimeSpan.Zero; SendParameter(HeartrateParameter.Enabled, true); + } + + private void sendParameters() + { + var normalisedHeartRate = Map(currentHeartrate, GetSetting(HeartrateSetting.NormalisedLowerbound), GetSetting(HeartrateSetting.NormalisedUpperbound), 0, 1); + var individualValues = toDigitArray(currentHeartrate, 3); + SendParameter(HeartrateParameter.Normalised, normalisedHeartRate); SendParameter(HeartrateParameter.Units, individualValues[2] / 10f); SendParameter(HeartrateParameter.Tens, individualValues[1] / 10f); @@ -108,6 +136,14 @@ private static int[] toDigitArray(int num, int totalWidth) return num.ToString().PadLeft(totalWidth, '0').Select(digit => int.Parse(digit.ToString())).ToArray(); } + protected enum HeartrateSetting + { + NormalisedLowerbound, + NormalisedUpperbound, + Smoothed, + SmoothingLength + } + protected enum HeartrateParameter { Enabled, @@ -116,4 +152,14 @@ protected enum HeartrateParameter Tens, Hundreds } + + private enum HeartrateState + { + Default + } + + private enum HeartrateVariable + { + Heartrate + } } diff --git a/VRCOSC.Modules/Heartrate/HypeRate/HypeRateModule.cs b/VRCOSC.Modules/Heartrate/HypeRate/HypeRateModule.cs index 5c7b9c01..c6f1cd5c 100644 --- a/VRCOSC.Modules/Heartrate/HypeRate/HypeRateModule.cs +++ b/VRCOSC.Modules/Heartrate/HypeRate/HypeRateModule.cs @@ -5,7 +5,7 @@ namespace VRCOSC.Modules.Heartrate.HypeRate; -public sealed partial class HypeRateModule : HeartRateModule +public sealed class HypeRateModule : HeartRateModule { public override string Title => "HypeRate"; public override string Description => "Connects to HypeRate.io and sends your heartrate to VRChat"; diff --git a/VRCOSC.Modules/Heartrate/Pulsoid/PulsoidModule.cs b/VRCOSC.Modules/Heartrate/Pulsoid/PulsoidModule.cs index d9e32257..40bc0720 100644 --- a/VRCOSC.Modules/Heartrate/Pulsoid/PulsoidModule.cs +++ b/VRCOSC.Modules/Heartrate/Pulsoid/PulsoidModule.cs @@ -3,7 +3,7 @@ namespace VRCOSC.Modules.Heartrate.Pulsoid; -public sealed partial class PulsoidModule : HeartRateModule +public sealed class PulsoidModule : HeartRateModule { private const string pulsoid_access_token_url = "https://pulsoid.net/oauth2/authorize?response_type=token&client_id=a31caa68-b6ac-4680-976a-9761b915a1e3&redirect_uri=&scope=data:heart_rate:read&state=a52beaeb-c491-4cd3-b915-16fed71e17a8&response_mode=web_page"; diff --git a/VRCOSC.Modules/Media/MediaModule.cs b/VRCOSC.Modules/Media/MediaModule.cs index d879bd42..816054ad 100644 --- a/VRCOSC.Modules/Media/MediaModule.cs +++ b/VRCOSC.Modules/Media/MediaModule.cs @@ -5,22 +5,19 @@ using Windows.Media; using osu.Framework.Bindables; using VRCOSC.Game.Modules; +using VRCOSC.Game.Modules.ChatBox; using VRCOSC.Game.Providers.Media; namespace VRCOSC.Modules.Media; -public sealed partial class MediaModule : ChatBoxModule +public class MediaModule : ChatBoxModule { public override string Title => "Media"; public override string Description => "Integration with Windows Media"; public override string Author => "VolcanicArts"; public override string Prefab => "VRCOSC-Media"; - protected override TimeSpan DeltaUpdate => TimeSpan.FromSeconds(2); + protected override TimeSpan DeltaUpdate => TimeSpan.FromSeconds(1); public override ModuleType Type => ModuleType.Integrations; - protected override int ChatBoxPriority => 2; - - protected override string DefaultChatBoxFormat => @"[%curtime%/%duration%] Now Playing: %artist% - %title%"; - protected override IEnumerable ChatBoxFormatValues => new[] { @"%title%", @"%artist%", @"%curtime%", @"%duration%", @"%volume%" }; private readonly WindowsMediaProvider mediaProvider = new(); private readonly Bindable currentlySeeking = new(); @@ -29,16 +26,14 @@ public sealed partial class MediaModule : ChatBoxModule public MediaModule() { mediaProvider.OnPlaybackStateUpdate += onPlaybackStateUpdate; + mediaProvider.OnTrackChange += onTrackChange; } protected override void CreateAttributes() { - CreateSetting(MediaSetting.PausedBehaviour, "Paused Behaviour", "When the media is paused, should the ChatBox be empty or display that it's paused?", MediaPausedBehaviour.Empty); - CreateSetting(MediaSetting.PausedText, "Paused Text", $"The text to display when media is paused. Only applicable when Paused Behaviour is set to {MediaPausedBehaviour.Display}", "[Paused]", () => GetSetting(MediaSetting.PausedBehaviour) == MediaPausedBehaviour.Display); + // TODO - Remove into a separate screen that allows for start options CreateSetting(MediaSetting.StartList, "Start List", "A list of exe locations to start with this module. This is handy for starting media apps on module start. For example, Spotify", new[] { @$"C:\Users\{Environment.UserName}\AppData\Roaming\Spotify\spotify.exe" }, true); - base.CreateAttributes(); - CreateParameter(MediaParameter.Play, ParameterMode.ReadWrite, @"VRCOSC/Media/Play", "Play/Pause", @"True for playing. False for paused"); CreateParameter(MediaParameter.Volume, ParameterMode.ReadWrite, @"VRCOSC/Media/Volume", "Volume", @"The volume of the process that is controlling the media"); CreateParameter(MediaParameter.Repeat, ParameterMode.ReadWrite, @"VRCOSC/Media/Repeat", "Repeat", @"0 for disabled. 1 for single. 2 for list"); @@ -47,39 +42,42 @@ protected override void CreateAttributes() CreateParameter(MediaParameter.Previous, ParameterMode.Read, @"VRCOSC/Media/Previous", "Previous", @"Becoming true causes the previous track to play"); CreateParameter(MediaParameter.Seeking, ParameterMode.Read, @"VRCOSC/Media/Seeking", "Seeking", "Whether the user is currently seeking"); CreateParameter(MediaParameter.Position, ParameterMode.ReadWrite, @"VRCOSC/Media/Position", "Position", "The position of the song as a percentage"); - } - - protected override string? GetChatBoxText() - { - if (mediaProvider.Controller is null) return null; - - if (!mediaProvider.State.IsPlaying) - { - if (GetSetting(MediaSetting.PausedBehaviour) == MediaPausedBehaviour.Empty) return null; - return GetSetting(MediaSetting.PausedText) - .Replace(@"%title%", mediaProvider.State.Title) - .Replace(@"%artist%", mediaProvider.State.Artist) - .Replace(@"%volume%", (mediaProvider.State.Volume * 100).ToString("##0")); - } + CreateVariable(MediaVariable.Title, @"Title", @"title"); + CreateVariable(MediaVariable.Artist, @"Artist", @"artist"); + CreateVariable(MediaVariable.TrackNumber, @"Track Number", @"tracknumber"); + CreateVariable(MediaVariable.AlbumTitle, @"Album Title", @"albumtitle"); + CreateVariable(MediaVariable.AlbumArtist, @"Album Artist", @"albumartist"); + CreateVariable(MediaVariable.AlbumTrackCount, @"Album Track Count", @"albumtrackcount"); + CreateVariable(MediaVariable.Time, @"Time", @"time"); + CreateVariable(MediaVariable.Duration, @"Duration", @"duration"); + CreateVariable(MediaVariable.Volume, @"Volume", @"volume"); - var formattedText = GetSetting(ChatBoxSetting.ChatBoxFormat) - .Replace(@"%title%", mediaProvider.State.Title) - .Replace(@"%artist%", mediaProvider.State.Artist) - .Replace(@"%curtime%", mediaProvider.State.Position?.Position.ToString(@"mm\:ss")) - .Replace(@"%duration%", mediaProvider.State.Position?.EndTime.ToString(@"mm\:ss")) - .Replace(@"%volume%", (mediaProvider.State.Volume * 100).ToString("##0")); + CreateState(MediaState.Playing, "Playing", $@"[{GetVariableFormat(MediaVariable.Time)}/{GetVariableFormat(MediaVariable.Duration)}] Now Playing: {GetVariableFormat(MediaVariable.Artist)} - {GetVariableFormat(MediaVariable.Title)}"); + CreateState(MediaState.Paused, "Paused", @"[Paused]"); - return formattedText; + CreateEvent(MediaEvent.NowPlaying, "Now Playing", $@"[Now Playing] {GetVariableFormat(MediaVariable.Artist)} - {GetVariableFormat(MediaVariable.Title)}", 5); } protected override void OnModuleStart() { - base.OnModuleStart(); - mediaProvider.Hook(); + hookIntoMedia(); startProcesses(); } + private void hookIntoMedia() => Task.Run(async () => + { + var result = await mediaProvider.Hook(); + + if (!result) + { + Log("Could not hook into Windows media"); + Log("Try restarting the modules\nIf this persists you will need to restart your PC as Windows has not initialised media correctly"); + } + + ChangeStateTo(mediaProvider.State.IsPlaying ? MediaState.Playing : MediaState.Paused); + }); + private void startProcesses() { GetSetting>(MediaSetting.StartList).ForEach(processExeLocation => @@ -106,11 +104,33 @@ protected override void OnAvatarChange() protected override void OnModuleUpdate() { if (mediaProvider.Controller is not null) sendUpdatableParameters(); + + updateVariables(); + } + + private void updateVariables() + { + SetVariableValue(MediaVariable.Title, mediaProvider.State.Title); + SetVariableValue(MediaVariable.Artist, mediaProvider.State.Artist); + SetVariableValue(MediaVariable.TrackNumber, mediaProvider.State.TrackNumber.ToString()); + SetVariableValue(MediaVariable.AlbumTitle, mediaProvider.State.AlbumTitle); + SetVariableValue(MediaVariable.AlbumArtist, mediaProvider.State.AlbumArtist); + SetVariableValue(MediaVariable.AlbumTrackCount, mediaProvider.State.AlbumTrackCount.ToString()); + SetVariableValue(MediaVariable.Time, mediaProvider.State.Position?.Position.ToString(@"mm\:ss")); + SetVariableValue(MediaVariable.Duration, mediaProvider.State.Position?.EndTime.ToString(@"mm\:ss")); + SetVariableValue(MediaVariable.Volume, (mediaProvider.State.Volume * 100).ToString("##0")); } private void onPlaybackStateUpdate() { sendMediaParameters(); + ChangeStateTo(mediaProvider.State.IsPlaying ? MediaState.Playing : MediaState.Paused); + } + + private void onTrackChange() + { + updateVariables(); + TriggerEvent(MediaEvent.NowPlaying); } private void sendMediaParameters() @@ -196,15 +216,31 @@ protected override void OnIntParameterReceived(Enum key, int value) private enum MediaSetting { - PausedBehaviour, - PausedText, StartList } - private enum MediaPausedBehaviour + private enum MediaState { - Empty, - Display + Playing, + Paused + } + + private enum MediaEvent + { + NowPlaying + } + + private enum MediaVariable + { + Title, + Artist, + Time, + Duration, + Volume, + TrackNumber, + AlbumTitle, + AlbumArtist, + AlbumTrackCount } private enum MediaParameter diff --git a/VRCOSC.Modules/OpenVR/GestureExtensionsModule.cs b/VRCOSC.Modules/OpenVR/GestureExtensionsModule.cs index ada0dfdd..e771e38d 100644 --- a/VRCOSC.Modules/OpenVR/GestureExtensionsModule.cs +++ b/VRCOSC.Modules/OpenVR/GestureExtensionsModule.cs @@ -7,7 +7,7 @@ namespace VRCOSC.Modules.OpenVR; -public partial class GestureExtensionsModule : Module +public class GestureExtensionsModule : Module { public override string Title => "Gesture Extensions"; public override string Description => "Detect a range of custom gestures from Index controllers"; diff --git a/VRCOSC.Modules/OpenVR/OpenVRControllerStatisticsModule.cs b/VRCOSC.Modules/OpenVR/OpenVRControllerStatisticsModule.cs index 5010178c..7e47f7e5 100644 --- a/VRCOSC.Modules/OpenVR/OpenVRControllerStatisticsModule.cs +++ b/VRCOSC.Modules/OpenVR/OpenVRControllerStatisticsModule.cs @@ -6,7 +6,7 @@ namespace VRCOSC.Modules.OpenVR; -public partial class OpenVRControllerStatisticsModule : Module +public class OpenVRControllerStatisticsModule : Module { public override string Title => "OpenVR Controller Statistics"; public override string Description => "Gets controller statistics from your OpenVR (SteamVR) session"; diff --git a/VRCOSC.Modules/OpenVR/OpenVRStatisticsModule.cs b/VRCOSC.Modules/OpenVR/OpenVRStatisticsModule.cs index dbf50de0..10ba83fa 100644 --- a/VRCOSC.Modules/OpenVR/OpenVRStatisticsModule.cs +++ b/VRCOSC.Modules/OpenVR/OpenVRStatisticsModule.cs @@ -1,29 +1,22 @@ // Copyright (c) VolcanicArts. Licensed under the GPL-3.0 License. // See the LICENSE file in the repository root for full license text. -using System.Globalization; using VRCOSC.Game.Modules; +using VRCOSC.Game.Modules.ChatBox; using VRCOSC.Game.OpenVR; namespace VRCOSC.Modules.OpenVR; -public partial class OpenVRStatisticsModule : ChatBoxModule +public class OpenVRStatisticsModule : ChatBoxModule { public override string Title => "OpenVR Statistics"; public override string Description => "Gets statistics from your OpenVR (SteamVR) session"; public override string Author => "VolcanicArts"; public override ModuleType Type => ModuleType.OpenVR; protected override TimeSpan DeltaUpdate => TimeSpan.FromSeconds(5); - protected override int ChatBoxPriority => 1; - - protected override bool DefaultChatBoxDisplay => false; - protected override IEnumerable ChatBoxFormatValues => new[] { "$fps$", @"$hmdbattery$", @"$leftcontrollerbattery$", @"$rightcontrollerbattery$" }; - protected override string DefaultChatBoxFormat => @"FPS: $fps$ | HMD: $hmdbattery$ | LC: $leftcontrollerbattery$ | RC: $rightcontrollerbattery$"; protected override void CreateAttributes() { - base.CreateAttributes(); - CreateParameter(OpenVrParameter.FPS, ParameterMode.Write, "VRCOSC/OpenVR/FPS", "FPS", "The current FPS normalised to 240 FPS"); CreateParameter(OpenVrParameter.HMD_Connected, ParameterMode.Write, "VRCOSC/OpenVR/HMD/Connected", "HMD Connected", "Whether your HMD is connected"); @@ -43,27 +36,41 @@ protected override void CreateAttributes() CreateParameter(OpenVrParameter.Tracker1_Battery + i, ParameterMode.Write, $"VRCOSC/OpenVR/Trackers/{i + 1}/Battery", $"Tracker {i + 1} Battery", $"The battery percentage normalised (0-1) of tracker {i + 1}"); CreateParameter(OpenVrParameter.Tracker1_Charging + i, ParameterMode.Write, $"VRCOSC/OpenVR/Trackers/{i + 1}/Charging", $"Tracker {i + 1} Charging", $"Whether tracker {i + 1} is currently charging"); } + + CreateVariable(OpenVrVariable.FPS, @"FPS", @"fps"); + CreateVariable(OpenVrVariable.HMDBattery, @"HMD Battery", @"hmdbattery"); + CreateVariable(OpenVrVariable.LeftControllerBattery, @"Left Controller Battery", @"leftcontrollerbattery"); + CreateVariable(OpenVrVariable.RightControllerBattery, @"Right Controller Battery", @"rightcontrollerbattery"); + + CreateState(OpenVrState.Default, "Default", $@"FPS: {GetVariableFormat(OpenVrVariable.FPS)} | HMD: {GetVariableFormat(OpenVrVariable.HMDBattery)} | LC: {GetVariableFormat(OpenVrVariable.LeftControllerBattery)} | RC: {GetVariableFormat(OpenVrVariable.RightControllerBattery)}"); } - protected override string? GetChatBoxText() + protected override void OnModuleStart() { - if (!OVRClient.HasInitialised) return null; - - return GetSetting(ChatBoxSetting.ChatBoxFormat) - .Replace("$fps$", OVRClient.System.FPS.ToString("00", CultureInfo.InvariantCulture)) - .Replace(@"$hmdbattery$", ((int)(OVRClient.HMD.BatteryPercentage * 100)).ToString(CultureInfo.InvariantCulture)) - .Replace(@"$leftcontrollerbattery$", ((int)(OVRClient.LeftController.BatteryPercentage * 100)).ToString(CultureInfo.InvariantCulture)) - .Replace(@"$rightcontrollerbattery$", ((int)(OVRClient.RightController.BatteryPercentage * 100)).ToString(CultureInfo.InvariantCulture)); + ChangeStateTo(OpenVrState.Default); } protected override void OnModuleUpdate() { - if (!OVRClient.HasInitialised) return; - - SendParameter(OpenVrParameter.FPS, OVRClient.System.FPS / 240.0f); - handleHmd(); - handleControllers(); - handleTrackers(); + if (OVRClient.HasInitialised) + { + SendParameter(OpenVrParameter.FPS, OVRClient.System.FPS / 240.0f); + handleHmd(); + handleControllers(); + handleTrackers(); + + SetVariableValue(OpenVrVariable.FPS, OVRClient.System.FPS.ToString("00")); + SetVariableValue(OpenVrVariable.HMDBattery, ((int)(OVRClient.HMD.BatteryPercentage * 100)).ToString()); + SetVariableValue(OpenVrVariable.LeftControllerBattery, ((int)(OVRClient.LeftController.BatteryPercentage * 100)).ToString()); + SetVariableValue(OpenVrVariable.RightControllerBattery, ((int)(OVRClient.RightController.BatteryPercentage * 100)).ToString()); + } + else + { + SetVariableValue(OpenVrVariable.FPS, "0"); + SetVariableValue(OpenVrVariable.HMDBattery, "0"); + SetVariableValue(OpenVrVariable.LeftControllerBattery, "0"); + SetVariableValue(OpenVrVariable.RightControllerBattery, "0"); + } } private void handleHmd() @@ -151,4 +158,17 @@ private enum OpenVrParameter Tracker7_Charging, Tracker8_Charging } + + private enum OpenVrState + { + Default + } + + private enum OpenVrVariable + { + FPS, + HMDBattery, + LeftControllerBattery, + RightControllerBattery + } } diff --git a/VRCOSC.Modules/Random/RandomBoolModule.cs b/VRCOSC.Modules/Random/RandomBoolModule.cs index e222f611..ed6a5987 100644 --- a/VRCOSC.Modules/Random/RandomBoolModule.cs +++ b/VRCOSC.Modules/Random/RandomBoolModule.cs @@ -3,7 +3,7 @@ namespace VRCOSC.Modules.Random; -public sealed partial class RandomBoolModule : RandomModule +public sealed class RandomBoolModule : RandomModule { protected override bool GetRandomValue() => RandomBool(); } diff --git a/VRCOSC.Modules/Random/RandomFloatModule.cs b/VRCOSC.Modules/Random/RandomFloatModule.cs index 35b7b0b4..ff804946 100644 --- a/VRCOSC.Modules/Random/RandomFloatModule.cs +++ b/VRCOSC.Modules/Random/RandomFloatModule.cs @@ -3,7 +3,7 @@ namespace VRCOSC.Modules.Random; -public sealed partial class RandomFloatModule : RandomModule +public sealed class RandomFloatModule : RandomModule { protected override void CreateAttributes() { diff --git a/VRCOSC.Modules/Random/RandomIntModule.cs b/VRCOSC.Modules/Random/RandomIntModule.cs index 8228d50c..abf38e35 100644 --- a/VRCOSC.Modules/Random/RandomIntModule.cs +++ b/VRCOSC.Modules/Random/RandomIntModule.cs @@ -3,7 +3,7 @@ namespace VRCOSC.Modules.Random; -public sealed partial class RandomIntModule : RandomModule +public sealed class RandomIntModule : RandomModule { protected override void CreateAttributes() { diff --git a/VRCOSC.Modules/Random/RandomModule.cs b/VRCOSC.Modules/Random/RandomModule.cs index dedac681..9493117f 100644 --- a/VRCOSC.Modules/Random/RandomModule.cs +++ b/VRCOSC.Modules/Random/RandomModule.cs @@ -6,7 +6,7 @@ namespace VRCOSC.Modules.Random; -public abstract partial class RandomModule : Module where T : struct +public abstract class RandomModule : Module where T : struct { public override string Title => $"Random {typeof(T).ToReadableName()}"; public override string Description => $"Sends a random {typeof(T).ToReadableName().ToLowerInvariant()} over a variable time period"; diff --git a/VRCOSC.Modules/VRCOSC.Modules.csproj b/VRCOSC.Modules/VRCOSC.Modules.csproj index 1df41808..c964904f 100644 --- a/VRCOSC.Modules/VRCOSC.Modules.csproj +++ b/VRCOSC.Modules/VRCOSC.Modules.csproj @@ -1,7 +1,7 @@ - net6.0-windows10.0.22000.0 + net6.0-windows10.0.22621.0 enable enable true diff --git a/VRCOSC.Modules/Weather/WeatherModule.cs b/VRCOSC.Modules/Weather/WeatherModule.cs index c027cfba..d37d6d24 100644 --- a/VRCOSC.Modules/Weather/WeatherModule.cs +++ b/VRCOSC.Modules/Weather/WeatherModule.cs @@ -3,20 +3,17 @@ using VRCOSC.Game; using VRCOSC.Game.Modules; +using VRCOSC.Game.Modules.ChatBox; namespace VRCOSC.Modules.Weather; -public partial class WeatherModule : ChatBoxModule +public class WeatherModule : ChatBoxModule { public override string Title => "Weather"; public override string Description => "Retrieves weather information for a specific area"; public override string Author => "VolcanicArts"; public override ModuleType Type => ModuleType.General; protected override TimeSpan DeltaUpdate => TimeSpan.FromMinutes(10); - protected override int ChatBoxPriority => 1; - - protected override IEnumerable ChatBoxFormatValues => new[] { @"%tempc%", @"%tempf%", @"%humidity%" }; - protected override string DefaultChatBoxFormat => @"Local Weather %tempc%C"; private WeatherProvider? weatherProvider; private Weather? currentWeather; @@ -25,31 +22,39 @@ protected override void CreateAttributes() { CreateSetting(WeatherSetting.Postcode, "Location", "The postcode/zip code or city name to retrieve weather data for", string.Empty); - base.CreateAttributes(); - CreateParameter(WeatherParameter.Code, ParameterMode.Write, "VRCOSC/Weather/Code", "Weather Code", "The current weather's code"); + + CreateVariable(WeatherVariable.TempC, @"Temp C", @"tempc"); + CreateVariable(WeatherVariable.TempF, @"Temp F", @"tempf"); + CreateVariable(WeatherVariable.Humidity, @"Humidity", @"humidity"); + CreateVariable(WeatherVariable.Condition, @"Condition", @"condition"); + + CreateState(WeatherState.Default, @"Default", $@"Local Weather {GetVariableFormat(WeatherVariable.Condition)} {GetVariableFormat(WeatherVariable.TempC)}C - {GetVariableFormat(WeatherVariable.TempF)}F"); } protected override void OnModuleStart() { - base.OnModuleStart(); - if (string.IsNullOrEmpty(GetSetting(WeatherSetting.Postcode))) Log("Please provide a postcode/zip code or city name"); weatherProvider = new WeatherProvider(Secrets.GetSecret(VRCOSCSecretsKeys.Weather)); currentWeather = null; + ChangeStateTo(WeatherState.Default); } protected override void OnModuleUpdate() { if (string.IsNullOrEmpty(GetSetting(WeatherSetting.Postcode))) return; - if (weatherProvider is null) return; + if (weatherProvider is null) + { + Log("Unable to connect to weather service"); + return; + } Task.Run(async () => { currentWeather = await weatherProvider.RetrieveFor(GetSetting(WeatherSetting.Postcode)); - sendParameters(); + updateParameters(); }); } @@ -60,24 +65,24 @@ protected override void OnModuleStop() protected override void OnAvatarChange() { - sendParameters(); - } - - protected override string? GetChatBoxText() - { - if (currentWeather is null) return null; - - return GetSetting(ChatBoxSetting.ChatBoxFormat) - .Replace(@"%tempc%", currentWeather.TempC.ToString("0.0")) - .Replace(@"%tempf%", currentWeather.TempF.ToString("0.0")) - .Replace("%humidity%", currentWeather.Humidity.ToString()); + updateParameters(); } - private void sendParameters() + private void updateParameters() { - if (currentWeather is null) return; + if (currentWeather is null) + { + Log("Cannot retrieve weather for provided location"); + Log("If you've entered a post/zip code, try your closest city's name"); + return; + } SendParameter(WeatherParameter.Code, convertedWeatherCode); + + SetVariableValue(WeatherVariable.TempC, currentWeather.TempC.ToString("0.0")); + SetVariableValue(WeatherVariable.TempF, currentWeather.TempF.ToString("0.0")); + SetVariableValue(WeatherVariable.Humidity, currentWeather.Humidity.ToString()); + SetVariableValue(WeatherVariable.Condition, currentWeather.ConditionString); } private int convertedWeatherCode => currentWeather!.Condition.Code switch @@ -142,4 +147,17 @@ private enum WeatherParameter { Code } + + private enum WeatherState + { + Default + } + + private enum WeatherVariable + { + TempC, + TempF, + Humidity, + Condition + } } diff --git a/VRCOSC.Modules/Weather/WeatherProvider.cs b/VRCOSC.Modules/Weather/WeatherProvider.cs index 40a5fbff..384c677a 100644 --- a/VRCOSC.Modules/Weather/WeatherProvider.cs +++ b/VRCOSC.Modules/Weather/WeatherProvider.cs @@ -7,6 +7,8 @@ namespace VRCOSC.Modules.Weather; public class WeatherProvider { + private const string condition_url = "https://www.weatherapi.com/docs/weather_conditions.json"; + private readonly HttpClient httpClient = new(); private readonly string apiKey; @@ -20,6 +22,15 @@ public WeatherProvider(string apiKey) var uri = $"https://api.weatherapi.com/v1/current.json?key={apiKey}&q={postcode}"; var data = await httpClient.GetAsync(new Uri(uri)); var responseString = await data.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(responseString)?.Current; + var weatherResponse = JsonConvert.DeserializeObject(responseString)?.Current; + + if (weatherResponse is null) return null; + + using var client = new HttpClient(); + var response = await client.GetAsync(condition_url); + var conditionContent = await response.Content.ReadAsStringAsync(); + weatherResponse.ConditionString = JsonConvert.DeserializeObject>(conditionContent)?.Single(condition => condition.Code == weatherResponse.Condition.Code).Day ?? string.Empty; + + return weatherResponse; } } diff --git a/VRCOSC.Modules/Weather/WeatherResponse.cs b/VRCOSC.Modules/Weather/WeatherResponse.cs index 1900aea0..602dae4c 100644 --- a/VRCOSC.Modules/Weather/WeatherResponse.cs +++ b/VRCOSC.Modules/Weather/WeatherResponse.cs @@ -24,6 +24,9 @@ public class Weather [JsonProperty("condition")] public Condition Condition = null!; + + [JsonIgnore] + public string ConditionString = null!; } public class Condition @@ -31,3 +34,18 @@ public class Condition [JsonProperty("code")] public int Code; } + +public class WeatherCondition +{ + [JsonProperty("code")] + public int Code; + + [JsonProperty("day")] + public string Day = null!; + + [JsonProperty("night")] + public string Night = null!; + + [JsonProperty("icon")] + public int Icon; +} diff --git a/VRCOSC.Templates/VRCOSC.Templates.csproj b/VRCOSC.Templates/VRCOSC.Templates.csproj index df4f4680..52021bfd 100644 --- a/VRCOSC.Templates/VRCOSC.Templates.csproj +++ b/VRCOSC.Templates/VRCOSC.Templates.csproj @@ -11,7 +11,7 @@ true NU5128 - 2023.306.0 + 2023.423.0 VolcanicArts https://github.com/VolcanicArts/VRCOSC https://github.com/VolcanicArts/VRCOSC diff --git a/VRCOSC.Templates/templates/template-default/TemplateModule/TemplateModule.csproj b/VRCOSC.Templates/templates/template-default/TemplateModule/TemplateModule.csproj index 749868c2..468b3d7b 100644 --- a/VRCOSC.Templates/templates/template-default/TemplateModule/TemplateModule.csproj +++ b/VRCOSC.Templates/templates/template-default/TemplateModule/TemplateModule.csproj @@ -7,7 +7,7 @@ - +