diff --git a/OF DL.Core/Enumerations/Theme.cs b/OF DL.Core/Enumerations/Theme.cs new file mode 100644 index 0000000..7e7eefc --- /dev/null +++ b/OF DL.Core/Enumerations/Theme.cs @@ -0,0 +1,7 @@ +namespace OF_DL.Enumerations; + +public enum Theme +{ + light, + dark +} diff --git a/OF DL.Core/Models/Config/Config.cs b/OF DL.Core/Models/Config/Config.cs index 8bab3fa..7eeac51 100644 --- a/OF DL.Core/Models/Config/Config.cs +++ b/OF DL.Core/Models/Config/Config.cs @@ -89,6 +89,9 @@ public class Config : IFileNameFormatConfig [JsonConverter(typeof(StringEnumConverter))] public LoggingLevel LoggingLevel { get; set; } = LoggingLevel.Error; + [JsonConverter(typeof(StringEnumConverter))] + public Theme Theme { get; set; } = Theme.light; + [ToggleableConfig] public bool IgnoreOwnMessages { get; set; } [ToggleableConfig] public bool DisableBrowserAuth { get; set; } diff --git a/OF DL.Core/Services/ConfigService.cs b/OF DL.Core/Services/ConfigService.cs index c859d4a..d892b88 100644 --- a/OF DL.Core/Services/ConfigService.cs +++ b/OF DL.Core/Services/ConfigService.cs @@ -236,6 +236,9 @@ public class ConfigService(ILoggingService loggingService) : IConfigService LimitDownloadRate = hoconConfig.GetBoolean("Performance.LimitDownloadRate"), DownloadLimitInMbPerSec = hoconConfig.GetInt("Performance.DownloadLimitInMbPerSec"), + // Appearance Settings + Theme = ParseTheme(hoconConfig.GetString("Appearance.Theme", "light")), + // Logging/Debug Settings LoggingLevel = Enum.Parse(hoconConfig.GetString("Logging.LoggingLevel"), true) }; @@ -407,6 +410,11 @@ public class ConfigService(ILoggingService loggingService) : IConfigService hocon.AppendLine($" DownloadLimitInMbPerSec = {config.DownloadLimitInMbPerSec}"); hocon.AppendLine("}"); + hocon.AppendLine("# Appearance Settings"); + hocon.AppendLine("Appearance {"); + hocon.AppendLine($" Theme = \"{config.Theme.ToString().ToLower()}\""); + hocon.AppendLine("}"); + hocon.AppendLine("# Logging/Debug Settings"); hocon.AppendLine("Logging {"); hocon.AppendLine($" LoggingLevel = \"{config.LoggingLevel.ToString().ToLower()}\""); @@ -500,6 +508,16 @@ public class ConfigService(ILoggingService loggingService) : IConfigService return Enum.Parse("_" + value, true); } + private static Theme ParseTheme(string value) + { + if (Enum.TryParse(value, true, out Theme theme)) + { + return theme; + } + + return Theme.light; + } + private static double ParseDrmVideoDurationMatchThreshold(string value) => !double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) ? 0.98 diff --git a/OF DL.Gui/App.axaml b/OF DL.Gui/App.axaml index 1cb3638..6f7b6ff 100644 --- a/OF DL.Gui/App.axaml +++ b/OF DL.Gui/App.axaml @@ -3,6 +3,32 @@ xmlns:fluent="clr-namespace:Avalonia.Themes.Fluent;assembly=Avalonia.Themes.Fluent" RequestedThemeVariant="Light" x:Class="OF_DL.Gui.App"> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs b/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs index 1bc6830..bd67a69 100644 --- a/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs +++ b/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs @@ -1,6 +1,8 @@ using System.Collections.ObjectModel; using System.Reflection; using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Styling; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Newtonsoft.Json; @@ -41,7 +43,9 @@ public partial class ConfigFieldViewModel : ViewModelBase { ["_240"] = "240p", ["_720"] = "720p", - ["source"] = "Source Resolution" + ["source"] = "Source Resolution", + ["light"] = "Light", + ["dark"] = "Dark" }; public ConfigFieldViewModel( @@ -470,6 +474,7 @@ public partial class ConfigFieldViewModel : ViewModelBase HashSet allowedVariables = new(GetAllowedFileNameVariables(), StringComparer.OrdinalIgnoreCase); HashSet unknownVariables = new(StringComparer.OrdinalIgnoreCase); + (string PlainTextColor, string AllowedVariableColor, string InvalidVariableColor) = GetFileNamePreviewColors(); MatchCollection matches = s_fileNameVariableRegex.Matches(TextValue); int currentIndex = 0; @@ -478,13 +483,13 @@ public partial class ConfigFieldViewModel : ViewModelBase if (match.Index > currentIndex) { string plainText = TextValue[currentIndex..match.Index]; - FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(plainText, "#1F2A44")); + FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(plainText, PlainTextColor)); } string variableName = match.Groups[1].Value; bool isAllowedVariable = allowedVariables.Contains(variableName); FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(match.Value, - isAllowedVariable ? "#2E6EEA" : "#D84E4E")); + isAllowedVariable ? AllowedVariableColor : InvalidVariableColor)); if (!isAllowedVariable) { @@ -497,7 +502,7 @@ public partial class ConfigFieldViewModel : ViewModelBase if (currentIndex < TextValue.Length) { string trailingText = TextValue[currentIndex..]; - FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(trailingText, "#1F2A44")); + FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(trailingText, PlainTextColor)); } if (unknownVariables.Count > 0) @@ -507,6 +512,15 @@ public partial class ConfigFieldViewModel : ViewModelBase } } + private static (string PlainTextColor, string AllowedVariableColor, string InvalidVariableColor) + GetFileNamePreviewColors() + { + bool isDarkTheme = Application.Current?.RequestedThemeVariant == ThemeVariant.Dark; + return isDarkTheme + ? ("#DCE6F7", "#66A6FF", "#FF8C8C") + : ("#1F2A44", "#2E6EEA", "#D84E4E"); + } + private static string ToDisplayName(string propertyName) { if (string.IsNullOrWhiteSpace(propertyName)) diff --git a/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs b/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs index a2320a3..b19e784 100644 --- a/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs +++ b/OF DL.Gui/ViewModels/CreatorConfigModalViewModel.cs @@ -1,5 +1,7 @@ using System.Collections.ObjectModel; using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Styling; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using OF_DL.Models.Config; @@ -254,6 +256,7 @@ public partial class CreatorConfigModalViewModel : ViewModelBase HashSet allowedSet = new(allowedVariables, StringComparer.OrdinalIgnoreCase); HashSet unknownVariables = new(StringComparer.OrdinalIgnoreCase); + (string PlainTextColor, string AllowedVariableColor, string InvalidVariableColor) = GetFileNamePreviewColors(); MatchCollection matches = s_fileNameVariableRegex.Matches(format); int currentIndex = 0; @@ -262,12 +265,13 @@ public partial class CreatorConfigModalViewModel : ViewModelBase if (match.Index > currentIndex) { string plainText = format[currentIndex..match.Index]; - segments.Add(new FileNameFormatSegmentViewModel(plainText, "#1F2A44")); + segments.Add(new FileNameFormatSegmentViewModel(plainText, PlainTextColor)); } string variableName = match.Groups[1].Value; bool isAllowed = allowedSet.Contains(variableName); - segments.Add(new FileNameFormatSegmentViewModel(match.Value, isAllowed ? "#2E6EEA" : "#D84E4E")); + segments.Add(new FileNameFormatSegmentViewModel(match.Value, + isAllowed ? AllowedVariableColor : InvalidVariableColor)); if (!isAllowed) { @@ -280,7 +284,7 @@ public partial class CreatorConfigModalViewModel : ViewModelBase if (currentIndex < format.Length) { string trailingText = format[currentIndex..]; - segments.Add(new FileNameFormatSegmentViewModel(trailingText, "#1F2A44")); + segments.Add(new FileNameFormatSegmentViewModel(trailingText, PlainTextColor)); } if (unknownVariables.Count > 0) @@ -289,4 +293,13 @@ public partial class CreatorConfigModalViewModel : ViewModelBase setUnknownMessage($"Unknown variable(s): {tokens}"); } } + + private static (string PlainTextColor, string AllowedVariableColor, string InvalidVariableColor) + GetFileNamePreviewColors() + { + bool isDarkTheme = Application.Current?.RequestedThemeVariant == ThemeVariant.Dark; + return isDarkTheme + ? ("#DCE6F7", "#66A6FF", "#FF8C8C") + : ("#1F2A44", "#2E6EEA", "#D84E4E"); + } } diff --git a/OF DL.Gui/ViewModels/MainWindowViewModel.cs b/OF DL.Gui/ViewModels/MainWindowViewModel.cs index 150908e..9acbc6c 100644 --- a/OF DL.Gui/ViewModels/MainWindowViewModel.cs +++ b/OF DL.Gui/ViewModels/MainWindowViewModel.cs @@ -1,11 +1,15 @@ using System.Collections.ObjectModel; using System.ComponentModel; +using System.Text.RegularExpressions; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Media; +using Avalonia.Styling; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Avalonia.Threading; using Newtonsoft.Json; +using OF_DL.Enumerations; using OF_DL.Gui.Services; using OF_DL.Models; using OF_DL.Models.Config; @@ -124,10 +128,68 @@ public partial class MainWindowViewModel( "Enable download speed limiting.", [nameof(Config.DownloadLimitInMbPerSec)] = "Download rate limit in MB/s when rate limiting is enabled.", + [nameof(Config.Theme)] = + "GUI theme for the configuration and download screens.", [nameof(Config.LoggingLevel)] = "Log verbosity written to logs/OFDL.txt." }; + private static readonly Dictionary s_lightThemeBrushes = new(StringComparer.Ordinal) + { + ["WindowBackgroundBrush"] = "#EEF3FB", + ["SurfaceBackgroundBrush"] = "#FFFFFF", + ["SurfaceBorderBrush"] = "#DDE5F3", + ["PrimaryButtonBackgroundBrush"] = "#2E6EEA", + ["PrimaryButtonForegroundBrush"] = "#FFFFFF", + ["SecondaryButtonBackgroundBrush"] = "#FFFFFF", + ["SecondaryButtonForegroundBrush"] = "#1F2A44", + ["SecondaryButtonBorderBrush"] = "#CFD9EB", + ["TopBarBackgroundBrush"] = "#DDEAFF", + ["TopBarBorderBrush"] = "#CFD9EB", + ["TopBarTextBrush"] = "#304261", + ["TextPrimaryBrush"] = "#1F2A44", + ["TextSecondaryBrush"] = "#4A5B78", + ["HelpBadgeBackgroundBrush"] = "#EAF0FB", + ["HelpBadgeBorderBrush"] = "#C5D4EC", + ["ErrorTextBrush"] = "#FF5A5A", + ["PreviewBackgroundBrush"] = "#F5F8FE", + ["PreviewBorderBrush"] = "#D8E3F4", + ["DangerSoftBackgroundBrush"] = "#FFE8E8", + ["DangerSoftBorderBrush"] = "#E8C5C5", + ["DangerButtonBackgroundBrush"] = "#D84E4E", + ["OverlayBackgroundBrush"] = "#80000000", + ["ModalBackgroundBrush"] = "#FFFFFF", + ["ModalBorderBrush"] = "#DDE5F3" + }; + + private static readonly Dictionary s_darkThemeBrushes = new(StringComparer.Ordinal) + { + ["WindowBackgroundBrush"] = "#0F141D", + ["SurfaceBackgroundBrush"] = "#151C28", + ["SurfaceBorderBrush"] = "#2A3445", + ["PrimaryButtonBackgroundBrush"] = "#4C8DFF", + ["PrimaryButtonForegroundBrush"] = "#FFFFFF", + ["SecondaryButtonBackgroundBrush"] = "#1C2533", + ["SecondaryButtonForegroundBrush"] = "#DCE6F7", + ["SecondaryButtonBorderBrush"] = "#33425A", + ["TopBarBackgroundBrush"] = "#1A2433", + ["TopBarBorderBrush"] = "#33425A", + ["TopBarTextBrush"] = "#C7D6EE", + ["TextPrimaryBrush"] = "#DCE6F7", + ["TextSecondaryBrush"] = "#A8B8D2", + ["HelpBadgeBackgroundBrush"] = "#233145", + ["HelpBadgeBorderBrush"] = "#3A4E6A", + ["ErrorTextBrush"] = "#FF8C8C", + ["PreviewBackgroundBrush"] = "#1B2636", + ["PreviewBorderBrush"] = "#314359", + ["DangerSoftBackgroundBrush"] = "#3A2024", + ["DangerSoftBorderBrush"] = "#6A3A40", + ["DangerButtonBackgroundBrush"] = "#CC4A4A", + ["OverlayBackgroundBrush"] = "#99000000", + ["ModalBackgroundBrush"] = "#151C28", + ["ModalBorderBrush"] = "#2A3445" + }; + private Dictionary _allUsers = []; private Dictionary _allLists = []; private StartupResult _startupResult = new(); @@ -816,6 +878,7 @@ public partial class MainWindowViewModel( private async Task BeginStartupAsync() { + ApplyThemeFromConfigFileIfAvailable(); _configReturnScreen = CurrentScreen; SetLoading("Loading configuration..."); BuildConfigFields(configService.CurrentConfig); @@ -841,6 +904,34 @@ public partial class MainWindowViewModel( await EnsureAuthenticationAndLoadUsersAsync(); } + private static void ApplyThemeFromConfigFileIfAvailable() + { + const string configPath = "config.conf"; + if (!File.Exists(configPath)) + { + return; + } + + try + { + string configText = File.ReadAllText(configPath); + Match match = Regex.Match(configText, @"(?im)^\s*Theme\s*=\s*""(light|dark)"""); + if (!match.Success) + { + return; + } + + if (Enum.TryParse(match.Groups[1].Value, true, out Theme parsedTheme)) + { + ApplyConfiguredTheme(parsedTheme); + } + } + catch + { + // Ignore theme parsing errors here; full config validation happens in ConfigService. + } + } + private async Task EnsureAuthenticationAndLoadUsersAsync() { bool hasValidAuth = await TryLoadAndValidateExistingAuthAsync(); @@ -1070,6 +1161,8 @@ public partial class MainWindowViewModel( private void BuildConfigFields(Config config) { + ApplyConfiguredTheme(config.Theme); + ConfigFields.Clear(); ConfigCategories.Clear(); ConfigCategoriesLeft.Clear(); @@ -1311,6 +1404,23 @@ public partial class MainWindowViewModel( private static string EscapePathForConfig(string path) => path.Replace(@"\", @"\\"); + private static void ApplyConfiguredTheme(Theme theme) + { + if (Application.Current == null) + { + return; + } + + bool useDarkTheme = theme == Theme.dark; + Application.Current.RequestedThemeVariant = useDarkTheme ? ThemeVariant.Dark : ThemeVariant.Light; + + Dictionary palette = useDarkTheme ? s_darkThemeBrushes : s_lightThemeBrushes; + foreach (KeyValuePair brush in palette) + { + Application.Current.Resources[brush.Key] = new SolidColorBrush(Color.Parse(brush.Value)); + } + } + private static string GetConfigHelpText(string propertyName) => s_configHelpTextByProperty.TryGetValue(propertyName, out string? helpText) ? helpText @@ -1537,6 +1647,8 @@ public partial class MainWindowViewModel( nameof(Config.LimitDownloadRate) => "Performance", nameof(Config.DownloadLimitInMbPerSec) => "Performance", + nameof(Config.Theme) => "Appearance", + nameof(Config.LoggingLevel) => "Logging", _ => "Other" @@ -1553,7 +1665,8 @@ public partial class MainWindowViewModel( "Folder Structure" => 5, "Subscriptions" => 6, "Performance" => 7, - "Logging" => 8, + "Appearance" => 8, + "Logging" => 9, _ => 100 }; } diff --git a/OF DL.Gui/Views/MainWindow.axaml b/OF DL.Gui/Views/MainWindow.axaml index 59a7d2f..c8d519e 100644 --- a/OF DL.Gui/Views/MainWindow.axaml +++ b/OF DL.Gui/Views/MainWindow.axaml @@ -11,30 +11,30 @@ MinWidth="1150" MinHeight="700" Title="OF DL" - Background="#EEF3FB" + Background="{DynamicResource WindowBackgroundBrush}" mc:Ignorable="d"> @@ -57,11 +57,11 @@ + BorderBrush="{DynamicResource TopBarBorderBrush}" + Background="{DynamicResource TopBarBackgroundBrush}"> - +