From 1f7414ead363fdd3bc21267fcfe93464be768034 Mon Sep 17 00:00:00 2001 From: real-zony Date: Fri, 9 Jan 2026 23:38:57 +0800 Subject: [PATCH] feat: Create a new GUI project based on Avalonia. (Powered by Claude) --- .../Localization/CliLocalizationService.cs | 37 +++ .../Resources/CommandStrings.cs | 10 + .../Resources/CommandStrings.en-US.resx | 92 +++++++ .../Resources/CommandStrings.resx | 92 +++++++ .../Exceptions/IErrorCodeHelper.cs | 21 ++ .../Localization/ILocalizationService.cs | 44 +++ .../Localization/LocalizationService.cs | 72 +++++ .../Resources/ErrorMessages.cs | 10 + .../Resources/ErrorMessages.en-US.resx | 95 +++++++ .../Resources/ErrorMessages.resx | 95 +++++++ src/ZonyLrcTools.Common/Resources/Messages.cs | 10 + .../Resources/Messages.en-US.resx | 82 ++++++ .../Resources/Messages.resx | 82 ++++++ src/ZonyLrcTools.Desktop/App.axaml | 33 +++ src/ZonyLrcTools.Desktop/App.axaml.cs | 47 ++++ .../Localization/LocalizeExtension.cs | 142 ++++++++++ src/ZonyLrcTools.Desktop/Program.cs | 23 ++ .../Resources/UIStrings.cs | 10 + .../Resources/UIStrings.en-US.resx | 245 +++++++++++++++++ .../Resources/UIStrings.resx | 245 +++++++++++++++++ .../Services/AvaloniaWarpLogger.cs | 67 +++++ .../Services/DialogService.cs | 60 +++++ .../Services/IDialogService.cs | 9 + .../Services/INavigationService.cs | 13 + .../Services/IThemeService.cs | 12 + .../Services/NavigationService.cs | 50 ++++ .../Services/ServiceCollectionExtensions.cs | 76 ++++++ .../Services/ThemeService.cs | 28 ++ .../ViewModels/AlbumDownloadViewModel.cs | 137 ++++++++++ .../ViewModels/HomeViewModel.cs | 15 ++ .../ViewModels/LyricsDownloadViewModel.cs | 164 ++++++++++++ .../ViewModels/MainWindowViewModel.cs | 72 +++++ .../ViewModels/SettingsViewModel.cs | 253 ++++++++++++++++++ .../ViewModels/ViewModelBase.cs | 28 ++ .../Views/MainWindow.axaml | 81 ++++++ .../Views/MainWindow.axaml.cs | 38 +++ .../Views/Pages/AlbumDownloadPage.axaml | 142 ++++++++++ .../Views/Pages/AlbumDownloadPage.axaml.cs | 11 + .../Views/Pages/HomePage.axaml | 97 +++++++ .../Views/Pages/HomePage.axaml.cs | 11 + .../Views/Pages/LyricsDownloadPage.axaml | 145 ++++++++++ .../Views/Pages/LyricsDownloadPage.axaml.cs | 11 + .../Views/Pages/SettingsPage.axaml | 141 ++++++++++ .../Views/Pages/SettingsPage.axaml.cs | 11 + .../ZonyLrcTools.Desktop.csproj | 48 ++++ src/ZonyLrcTools.Desktop/app.manifest | 18 ++ 46 files changed, 3225 insertions(+) create mode 100644 src/ZonyLrcTools.Cli/Infrastructure/Localization/CliLocalizationService.cs create mode 100644 src/ZonyLrcTools.Cli/Resources/CommandStrings.cs create mode 100644 src/ZonyLrcTools.Cli/Resources/CommandStrings.en-US.resx create mode 100644 src/ZonyLrcTools.Cli/Resources/CommandStrings.resx create mode 100644 src/ZonyLrcTools.Common/Infrastructure/Exceptions/IErrorCodeHelper.cs create mode 100644 src/ZonyLrcTools.Common/Infrastructure/Localization/ILocalizationService.cs create mode 100644 src/ZonyLrcTools.Common/Infrastructure/Localization/LocalizationService.cs create mode 100644 src/ZonyLrcTools.Common/Resources/ErrorMessages.cs create mode 100644 src/ZonyLrcTools.Common/Resources/ErrorMessages.en-US.resx create mode 100644 src/ZonyLrcTools.Common/Resources/ErrorMessages.resx create mode 100644 src/ZonyLrcTools.Common/Resources/Messages.cs create mode 100644 src/ZonyLrcTools.Common/Resources/Messages.en-US.resx create mode 100644 src/ZonyLrcTools.Common/Resources/Messages.resx create mode 100644 src/ZonyLrcTools.Desktop/App.axaml create mode 100644 src/ZonyLrcTools.Desktop/App.axaml.cs create mode 100644 src/ZonyLrcTools.Desktop/Infrastructure/Localization/LocalizeExtension.cs create mode 100644 src/ZonyLrcTools.Desktop/Program.cs create mode 100644 src/ZonyLrcTools.Desktop/Resources/UIStrings.cs create mode 100644 src/ZonyLrcTools.Desktop/Resources/UIStrings.en-US.resx create mode 100644 src/ZonyLrcTools.Desktop/Resources/UIStrings.resx create mode 100644 src/ZonyLrcTools.Desktop/Services/AvaloniaWarpLogger.cs create mode 100644 src/ZonyLrcTools.Desktop/Services/DialogService.cs create mode 100644 src/ZonyLrcTools.Desktop/Services/IDialogService.cs create mode 100644 src/ZonyLrcTools.Desktop/Services/INavigationService.cs create mode 100644 src/ZonyLrcTools.Desktop/Services/IThemeService.cs create mode 100644 src/ZonyLrcTools.Desktop/Services/NavigationService.cs create mode 100644 src/ZonyLrcTools.Desktop/Services/ServiceCollectionExtensions.cs create mode 100644 src/ZonyLrcTools.Desktop/Services/ThemeService.cs create mode 100644 src/ZonyLrcTools.Desktop/ViewModels/AlbumDownloadViewModel.cs create mode 100644 src/ZonyLrcTools.Desktop/ViewModels/HomeViewModel.cs create mode 100644 src/ZonyLrcTools.Desktop/ViewModels/LyricsDownloadViewModel.cs create mode 100644 src/ZonyLrcTools.Desktop/ViewModels/MainWindowViewModel.cs create mode 100644 src/ZonyLrcTools.Desktop/ViewModels/SettingsViewModel.cs create mode 100644 src/ZonyLrcTools.Desktop/ViewModels/ViewModelBase.cs create mode 100644 src/ZonyLrcTools.Desktop/Views/MainWindow.axaml create mode 100644 src/ZonyLrcTools.Desktop/Views/MainWindow.axaml.cs create mode 100644 src/ZonyLrcTools.Desktop/Views/Pages/AlbumDownloadPage.axaml create mode 100644 src/ZonyLrcTools.Desktop/Views/Pages/AlbumDownloadPage.axaml.cs create mode 100644 src/ZonyLrcTools.Desktop/Views/Pages/HomePage.axaml create mode 100644 src/ZonyLrcTools.Desktop/Views/Pages/HomePage.axaml.cs create mode 100644 src/ZonyLrcTools.Desktop/Views/Pages/LyricsDownloadPage.axaml create mode 100644 src/ZonyLrcTools.Desktop/Views/Pages/LyricsDownloadPage.axaml.cs create mode 100644 src/ZonyLrcTools.Desktop/Views/Pages/SettingsPage.axaml create mode 100644 src/ZonyLrcTools.Desktop/Views/Pages/SettingsPage.axaml.cs create mode 100644 src/ZonyLrcTools.Desktop/ZonyLrcTools.Desktop.csproj create mode 100644 src/ZonyLrcTools.Desktop/app.manifest diff --git a/src/ZonyLrcTools.Cli/Infrastructure/Localization/CliLocalizationService.cs b/src/ZonyLrcTools.Cli/Infrastructure/Localization/CliLocalizationService.cs new file mode 100644 index 0000000..7fa2328 --- /dev/null +++ b/src/ZonyLrcTools.Cli/Infrastructure/Localization/CliLocalizationService.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Localization; +using ZonyLrcTools.Common.Infrastructure.DependencyInject; + +namespace ZonyLrcTools.Cli.Infrastructure.Localization; + +/// +/// Provides localization services for CLI commands and options. +/// +public interface ICliLocalizationService +{ + /// + /// Gets a localized string by key. + /// + string this[string key] { get; } +} + +/// +/// Implementation of CLI localization service. +/// +public class CliLocalizationService : ICliLocalizationService, ISingletonDependency +{ + private readonly IStringLocalizer _localizer; + + public CliLocalizationService(IStringLocalizer localizer) + { + _localizer = localizer; + } + + public string this[string key] + { + get + { + var value = _localizer[key]; + return value.ResourceNotFound ? $"[{key}]" : value.Value; + } + } +} diff --git a/src/ZonyLrcTools.Cli/Resources/CommandStrings.cs b/src/ZonyLrcTools.Cli/Resources/CommandStrings.cs new file mode 100644 index 0000000..caac57d --- /dev/null +++ b/src/ZonyLrcTools.Cli/Resources/CommandStrings.cs @@ -0,0 +1,10 @@ +namespace ZonyLrcTools.Cli; + +/// +/// Marker class for CLI command localization resources. +/// This class is used by IStringLocalizer<CommandStrings> to locate the resource files. +/// The namespace must be the root namespace so that IStringLocalizer looks for Resources/CommandStrings.resx +/// +public class CommandStrings +{ +} diff --git a/src/ZonyLrcTools.Cli/Resources/CommandStrings.en-US.resx b/src/ZonyLrcTools.Cli/Resources/CommandStrings.en-US.resx new file mode 100644 index 0000000..23d983d --- /dev/null +++ b/src/ZonyLrcTools.Cli/Resources/CommandStrings.en-US.resx @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ZonyLrcTools - Lyrics download command-line tool + + + + Download lyrics or album covers + + + Specify the folder path containing music files + + + Download lyrics files + + + Download album covers + + + Import song list from CSV file for download + + + Import song list from NetEase playlist ID for download + + + + Utility commands + + + Convert encrypted music files to normal format + + + Specify the folder path to convert + + + + Search for lyrics + + + Song name + + + Artist name + + + + Show help information + + + Show version information + + diff --git a/src/ZonyLrcTools.Cli/Resources/CommandStrings.resx b/src/ZonyLrcTools.Cli/Resources/CommandStrings.resx new file mode 100644 index 0000000..b57300b --- /dev/null +++ b/src/ZonyLrcTools.Cli/Resources/CommandStrings.resx @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ZonyLrcTools - 歌词下载命令行工具 + + + + 下载歌词或专辑封面 + + + 指定音乐文件所在的文件夹路径 + + + 下载歌词文件 + + + 下载专辑封面 + + + 从 CSV 文件导入歌曲列表进行下载 + + + 从网易云歌单 ID 导入歌曲列表进行下载 + + + + 工具类命令 + + + 转换加密音乐文件为普通格式 + + + 指定需要转换的文件夹路径 + + + + 搜索歌词 + + + 歌曲名称 + + + 艺术家名称 + + + + 显示帮助信息 + + + 显示版本信息 + + diff --git a/src/ZonyLrcTools.Common/Infrastructure/Exceptions/IErrorCodeHelper.cs b/src/ZonyLrcTools.Common/Infrastructure/Exceptions/IErrorCodeHelper.cs new file mode 100644 index 0000000..e13320f --- /dev/null +++ b/src/ZonyLrcTools.Common/Infrastructure/Exceptions/IErrorCodeHelper.cs @@ -0,0 +1,21 @@ +namespace ZonyLrcTools.Common.Infrastructure.Exceptions; + +/// +/// 错误码帮助类接口。 +/// +public interface IErrorCodeHelper +{ + /// + /// 获取错误消息。 + /// + /// 错误代码。 + /// 对应的错误消息。 + string GetMessage(int errorCode); + + /// + /// 获取警告消息。 + /// + /// 警告代码。 + /// 对应的警告消息。 + string GetWarningMessage(int warningCode); +} diff --git a/src/ZonyLrcTools.Common/Infrastructure/Localization/ILocalizationService.cs b/src/ZonyLrcTools.Common/Infrastructure/Localization/ILocalizationService.cs new file mode 100644 index 0000000..33199ae --- /dev/null +++ b/src/ZonyLrcTools.Common/Infrastructure/Localization/ILocalizationService.cs @@ -0,0 +1,44 @@ +using System.Globalization; + +namespace ZonyLrcTools.Common.Infrastructure.Localization; + +/// +/// Provides localization services for the application. +/// +public interface ILocalizationService +{ + /// + /// Gets a localized string by key. + /// + string this[string key] { get; } + + /// + /// Gets a localized string by key with format arguments. + /// + string this[string key, params object[] args] { get; } + + /// + /// Gets an error message by error code. + /// + string GetErrorMessage(int errorCode); + + /// + /// Gets a warning message by warning code. + /// + string GetWarningMessage(int warningCode); + + /// + /// Sets the current culture. + /// + void SetCulture(string cultureName); + + /// + /// Gets the current culture name. + /// + string CurrentCulture { get; } + + /// + /// Gets the list of supported cultures. + /// + IReadOnlyList SupportedCultures { get; } +} diff --git a/src/ZonyLrcTools.Common/Infrastructure/Localization/LocalizationService.cs b/src/ZonyLrcTools.Common/Infrastructure/Localization/LocalizationService.cs new file mode 100644 index 0000000..c5019ed --- /dev/null +++ b/src/ZonyLrcTools.Common/Infrastructure/Localization/LocalizationService.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using Microsoft.Extensions.Localization; +using ZonyLrcTools.Common.Infrastructure.DependencyInject; + +namespace ZonyLrcTools.Common.Infrastructure.Localization; + +/// +/// Implementation of using .NET localization. +/// +public class LocalizationService : ILocalizationService, ISingletonDependency +{ + private readonly IStringLocalizer _messagesLocalizer; + private readonly IStringLocalizer _errorLocalizer; + + private static readonly CultureInfo[] _supportedCultures = + { + new("zh-CN"), + new("en-US") + }; + + public LocalizationService( + IStringLocalizer messagesLocalizer, + IStringLocalizer errorLocalizer) + { + _messagesLocalizer = messagesLocalizer; + _errorLocalizer = errorLocalizer; + } + + public string this[string key] + { + get + { + var value = _messagesLocalizer[key]; + return value.ResourceNotFound ? $"[{key}]" : value.Value; + } + } + + public string this[string key, params object[] args] + { + get + { + var value = _messagesLocalizer[key]; + if (value.ResourceNotFound) return $"[{key}]"; + return string.Format(value.Value, args); + } + } + + public string GetErrorMessage(int errorCode) + { + var key = $"Error_{errorCode}"; + var value = _errorLocalizer[key]; + return value.ResourceNotFound ? $"Unknown error: {errorCode}" : value.Value; + } + + public string GetWarningMessage(int warningCode) + { + var key = $"Warning_{warningCode}"; + var value = _errorLocalizer[key]; + return value.ResourceNotFound ? $"Unknown warning: {warningCode}" : value.Value; + } + + public string CurrentCulture => CultureInfo.CurrentUICulture.Name; + + public IReadOnlyList SupportedCultures => _supportedCultures; + + public void SetCulture(string cultureName) + { + var culture = new CultureInfo(cultureName); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + } +} diff --git a/src/ZonyLrcTools.Common/Resources/ErrorMessages.cs b/src/ZonyLrcTools.Common/Resources/ErrorMessages.cs new file mode 100644 index 0000000..35e9533 --- /dev/null +++ b/src/ZonyLrcTools.Common/Resources/ErrorMessages.cs @@ -0,0 +1,10 @@ +namespace ZonyLrcTools.Common; + +/// +/// Marker class for error message localization resources. +/// This class is used by IStringLocalizer<ErrorMessages> to locate the resource files. +/// The namespace must be the root namespace so that IStringLocalizer looks for Resources/ErrorMessages.resx +/// +public class ErrorMessages +{ +} diff --git a/src/ZonyLrcTools.Common/Resources/ErrorMessages.en-US.resx b/src/ZonyLrcTools.Common/Resources/ErrorMessages.en-US.resx new file mode 100644 index 0000000..4f119ef --- /dev/null +++ b/src/ZonyLrcTools.Common/Resources/ErrorMessages.en-US.resx @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + The file extension to search cannot be empty. + + + The directory to scan does not exist, please verify the path. + + + Unable to get the file extension information. + + + No music files were found. + + + The specified encoding is not supported, please check the configuration. + + + Unable to get the song list from NetEase Music. + + + + An error occurred while scanning files. + + + Both song name and artist name are empty, unable to search. + + + Song name cannot be empty, unable to search. + + + The downloader did not find the corresponding song information. + + + The download request returned an invalid response, possibly a server error. + + + Tag info reader is empty, unable to parse music tag information. + + + TagLib tag reader encountered an unexpected exception. + + + Service interface restricted, unable to make request, please try using a proxy server. + + + HTTP request to target server failed. + + + Failed to deserialize HTTP response to JSON. + + + Currently only NCM format song conversion is supported. + + diff --git a/src/ZonyLrcTools.Common/Resources/ErrorMessages.resx b/src/ZonyLrcTools.Common/Resources/ErrorMessages.resx new file mode 100644 index 0000000..c4bb899 --- /dev/null +++ b/src/ZonyLrcTools.Common/Resources/ErrorMessages.resx @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + 待搜索的后缀不能为空。 + + + 需要扫描的目录不存在,请确认路径是否正确。 + + + 不能获取文件的后缀信息。 + + + 没有扫描到任何音乐文件。 + + + 指定的编码不受支持,请检查配置。 + + + 无法从网易云音乐获取歌曲列表。 + + + + 扫描文件时出现了错误。 + + + 歌曲名称或歌手名称均为空,无法进行搜索。 + + + 歌曲名称不能为空,无法进行搜索。 + + + 下载器没有搜索到对应的歌曲信息。 + + + 下载请求的返回值不合法,可能是服务端故障。 + + + 标签信息读取器为空,无法解析音乐 Tag 信息。 + + + TagLib 标签读取器出现了预期之外的异常。 + + + 服务接口限制,无法进行请求,请尝试使用代理服务器。 + + + 对目标服务器执行 HTTP 请求失败。 + + + HTTP 请求的结果反序列化为 JSON 失败。 + + + 目前仅支持 NCM 格式的歌曲转换操作。 + + diff --git a/src/ZonyLrcTools.Common/Resources/Messages.cs b/src/ZonyLrcTools.Common/Resources/Messages.cs new file mode 100644 index 0000000..338eed4 --- /dev/null +++ b/src/ZonyLrcTools.Common/Resources/Messages.cs @@ -0,0 +1,10 @@ +namespace ZonyLrcTools.Common; + +/// +/// Marker class for general message localization resources. +/// This class is used by IStringLocalizer<Messages> to locate the resource files. +/// The namespace must be the root namespace so that IStringLocalizer looks for Resources/Messages.resx +/// +public class Messages +{ +} diff --git a/src/ZonyLrcTools.Common/Resources/Messages.en-US.resx b/src/ZonyLrcTools.Common/Resources/Messages.en-US.resx new file mode 100644 index 0000000..7b8c844 --- /dev/null +++ b/src/ZonyLrcTools.Common/Resources/Messages.en-US.resx @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Starting lyrics file download... + + + Lyrics download complete, success: {0} skipped (instrumental): {1} failed: {2}. + + + Song: {0}, Artist: {1}, download successful. + + + Song: {0}, Artist: {1}, download failed. + + + + Starting album cover download... + + + Album cover download complete, success: {0} failed: {1}. + + + + Starting folder scan, please wait... + + + Scan complete, {0} files found. + + + + Starting... + + + An unhandled exception occurred. + + + Error code: {0} + + + Error message: {0} + + diff --git a/src/ZonyLrcTools.Common/Resources/Messages.resx b/src/ZonyLrcTools.Common/Resources/Messages.resx new file mode 100644 index 0000000..0d73327 --- /dev/null +++ b/src/ZonyLrcTools.Common/Resources/Messages.resx @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + 开始下载歌词文件数据... + + + 歌词数据下载完成,成功: {0} 跳过(纯音乐): {1} 失败: {2}。 + + + 歌曲名: {0}, 艺术家: {1}, 下载成功。 + + + 歌曲名: {0}, 艺术家: {1}, 下载失败。 + + + + 开始下载专辑封面... + + + 专辑封面下载完成,成功: {0} 失败: {1}。 + + + + 开始扫描文件夹,请稍等... + + + 扫描完成,共 {0} 个文件。 + + + + 正在启动... + + + 出现了未处理的异常。 + + + 错误代码: {0} + + + 错误信息: {0} + + diff --git a/src/ZonyLrcTools.Desktop/App.axaml b/src/ZonyLrcTools.Desktop/App.axaml new file mode 100644 index 0000000..7e38d66 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/App.axaml @@ -0,0 +1,33 @@ + + + + + + + + + + + #FFFFFF + #0078D4 + #F3F3F3 + #107C10 + #D13438 + #FFB900 + + + #1F1F1F + #60CDFF + #2D2D2D + #6CCB5F + #FF99A4 + #FCE100 + + + + + + diff --git a/src/ZonyLrcTools.Desktop/App.axaml.cs b/src/ZonyLrcTools.Desktop/App.axaml.cs new file mode 100644 index 0000000..4368b98 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/App.axaml.cs @@ -0,0 +1,47 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; +using Avalonia.Markup.Xaml; +using Microsoft.Extensions.DependencyInjection; +using ZonyLrcTools.Desktop.Services; +using ZonyLrcTools.Desktop.ViewModels; +using ZonyLrcTools.Desktop.Views; + +namespace ZonyLrcTools.Desktop; + +public partial class App : Application +{ + public static ServiceProvider? Services { get; private set; } + + public override void Initialize() + { + // Configure DI container BEFORE loading XAML + // This is required because LocalizeExtension needs App.Services during XAML loading + var services = new ServiceCollection(); + services.AddDesktopServices(); + Services = services.BuildServiceProvider(); + + // Initialize language settings before loading XAML + ServiceCollectionExtensions.InitializeLanguage(); + + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + // Avoid duplicate validations from both Avalonia and CommunityToolkit + BindingPlugins.DataValidators.RemoveAt(0); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var mainViewModel = Services!.GetRequiredService(); + + desktop.MainWindow = new MainWindow + { + DataContext = mainViewModel + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/ZonyLrcTools.Desktop/Infrastructure/Localization/LocalizeExtension.cs b/src/ZonyLrcTools.Desktop/Infrastructure/Localization/LocalizeExtension.cs new file mode 100644 index 0000000..0c6b11e --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Infrastructure/Localization/LocalizeExtension.cs @@ -0,0 +1,142 @@ +using Avalonia.Data; +using Avalonia.Markup.Xaml; +using Avalonia.Markup.Xaml.MarkupExtensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace ZonyLrcTools.Desktop.Infrastructure.Localization; + +/// +/// Avalonia markup extension for localization. +/// Usage: Text="{loc:Localize Key=Nav_Home}" +/// +public class LocalizeExtension : MarkupExtension +{ + public LocalizeExtension() + { + } + + public LocalizeExtension(string key) + { + Key = key; + } + + /// + /// The resource key to look up. + /// + public string? Key { get; set; } + + /// + /// Default value if the key is not found. + /// + public string? Default { get; set; } + + public override object ProvideValue(IServiceProvider serviceProvider) + { + if (string.IsNullOrEmpty(Key)) + { + return Default ?? "[NO_KEY]"; + } + + var localizer = App.Services?.GetService>(); + if (localizer == null) + { + return Default ?? $"[{Key}]"; + } + + var localizedValue = localizer[Key]; + if (localizedValue.ResourceNotFound) + { + return Default ?? $"[{Key}]"; + } + + return localizedValue.Value; + } +} + +/// +/// UI localization service for dynamic language switching. +/// +public interface IUILocalizationService +{ + /// + /// Gets a localized string by key. + /// + string this[string key] { get; } + + /// + /// Gets a localized string by key with format arguments. + /// + string this[string key, params object[] args] { get; } + + /// + /// Gets the current culture name. + /// + string CurrentCulture { get; } + + /// + /// Changes the current UI language. + /// + void SetLanguage(string cultureName); + + /// + /// Gets whether the language follows system settings. + /// + bool FollowSystem { get; set; } + + /// + /// Event raised when the language changes. + /// + event EventHandler? LanguageChanged; +} + +/// +/// Implementation of UI localization service. +/// +public class UILocalizationService : IUILocalizationService +{ + private readonly IStringLocalizer _localizer; + private bool _followSystem = true; + + public UILocalizationService(IStringLocalizer localizer) + { + _localizer = localizer; + } + + public string this[string key] + { + get + { + var value = _localizer[key]; + return value.ResourceNotFound ? $"[{key}]" : value.Value; + } + } + + public string this[string key, params object[] args] + { + get + { + var value = _localizer[key]; + if (value.ResourceNotFound) return $"[{key}]"; + return string.Format(value.Value, args); + } + } + + public string CurrentCulture => System.Globalization.CultureInfo.CurrentUICulture.Name; + + public bool FollowSystem + { + get => _followSystem; + set => _followSystem = value; + } + + public event EventHandler? LanguageChanged; + + public void SetLanguage(string cultureName) + { + var culture = new System.Globalization.CultureInfo(cultureName); + System.Globalization.CultureInfo.CurrentCulture = culture; + System.Globalization.CultureInfo.CurrentUICulture = culture; + LanguageChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/ZonyLrcTools.Desktop/Program.cs b/src/ZonyLrcTools.Desktop/Program.cs new file mode 100644 index 0000000..06c3b38 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Program.cs @@ -0,0 +1,23 @@ +using System; +using System.Text; +using Avalonia; + +namespace ZonyLrcTools.Desktop; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/src/ZonyLrcTools.Desktop/Resources/UIStrings.cs b/src/ZonyLrcTools.Desktop/Resources/UIStrings.cs new file mode 100644 index 0000000..fda7205 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Resources/UIStrings.cs @@ -0,0 +1,10 @@ +namespace ZonyLrcTools.Desktop; + +/// +/// Marker class for UI localization resources. +/// This class is used by IStringLocalizer<UIStrings> to locate the resource files. +/// The namespace must be the root namespace so that IStringLocalizer looks for Resources/UIStrings.resx +/// +public class UIStrings +{ +} diff --git a/src/ZonyLrcTools.Desktop/Resources/UIStrings.en-US.resx b/src/ZonyLrcTools.Desktop/Resources/UIStrings.en-US.resx new file mode 100644 index 0000000..c4133c5 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Resources/UIStrings.en-US.resx @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Home + + + Lyrics Download + + + Album Cover + + + Settings + + + + Welcome to ZonyLrcTools + + + An open-source lyrics download tool + + + Quick Start + + + Download Lyrics + + + Download Album Covers + + + + Lyrics Download + + + Batch download lyrics for your music files + + + Select music folder... + + + Browse... + + + Parallel: + + + Start Download + + + Stop Download + + + Download Progress + + + Idle + + + Scanning files... + + + Downloading lyrics... + + + Download Complete + + + Total: + + + files + + + Success: + + + Failed: + + + + Album Cover Download + + + Download album artwork for your music collection + + + Select music folder... + + + Browse... + + + Parallel: + + + Start Download + + + Stop Download + + + + Settings + + + Language + + + Follow System + + + Simplified Chinese + + + English + + + Theme + + + Light + + + Dark + + + Follow System + + + Lyrics Provider + + + Proxy Settings + + + Enable Proxy + + + Proxy Address + + + Proxy Port + + + Save Settings + + + Reset + + + + Song Name + + + Artist + + + Album + + + Status + + + File Path + + + + Success + + + Failed + + + Skipped + + + Pending + + + Processing + + + + OK + + + Cancel + + + Yes + + + No + + + Error + + + Warning + + + Info + + diff --git a/src/ZonyLrcTools.Desktop/Resources/UIStrings.resx b/src/ZonyLrcTools.Desktop/Resources/UIStrings.resx new file mode 100644 index 0000000..0f9e519 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Resources/UIStrings.resx @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + 首页 + + + 歌词下载 + + + 专辑封面 + + + 设置 + + + + 欢迎使用 ZonyLrcTools + + + 一款开源的歌词下载工具 + + + 快速开始 + + + 下载歌词 + + + 下载专辑封面 + + + + 歌词下载 + + + 批量下载音乐文件的歌词 + + + 选择音乐文件夹... + + + 浏览... + + + 并行: + + + 开始下载 + + + 停止下载 + + + 下载进度 + + + 等待中 + + + 正在扫描文件... + + + 正在下载歌词... + + + 下载完成 + + + 总计: + + + 个文件 + + + 成功: + + + 失败: + + + + 专辑封面下载 + + + 批量下载音乐文件的专辑封面 + + + 选择音乐文件夹... + + + 浏览... + + + 并行: + + + 开始下载 + + + 停止下载 + + + + 设置 + + + 语言 + + + 跟随系统 + + + 简体中文 + + + English + + + 主题 + + + 浅色 + + + 深色 + + + 跟随系统 + + + 歌词源 + + + 代理设置 + + + 启用代理 + + + 代理地址 + + + 代理端口 + + + 保存设置 + + + 重置 + + + + 歌曲名 + + + 艺术家 + + + 专辑 + + + 状态 + + + 文件路径 + + + + 成功 + + + 失败 + + + 已跳过 + + + 等待中 + + + 处理中 + + + + 确定 + + + 取消 + + + + + + + + + 错误 + + + 警告 + + + 提示 + + diff --git a/src/ZonyLrcTools.Desktop/Services/AvaloniaWarpLogger.cs b/src/ZonyLrcTools.Desktop/Services/AvaloniaWarpLogger.cs new file mode 100644 index 0000000..450dbc9 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Services/AvaloniaWarpLogger.cs @@ -0,0 +1,67 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using ZonyLrcTools.Common.Infrastructure.Logging; + +namespace ZonyLrcTools.Desktop.Services; + +/// +/// Avalonia implementation of IWarpLogger that supports UI binding. +/// +public class AvaloniaWarpLogger : IWarpLogger +{ + private const int MaxLogEntries = 1000; + + public ObservableCollection LogEntries { get; } = new(); + + public event EventHandler? LogAdded; + + public Task DebugAsync(string message, Exception? exception = null) + { + return AddLogAsync(LogLevel.Debug, message, exception); + } + + public Task InfoAsync(string message, Exception? exception = null) + { + return AddLogAsync(LogLevel.Info, message, exception); + } + + public Task WarnAsync(string message, Exception? exception = null) + { + return AddLogAsync(LogLevel.Warning, message, exception); + } + + public Task ErrorAsync(string message, Exception? exception = null) + { + return AddLogAsync(LogLevel.Error, message, exception); + } + + private async Task AddLogAsync(LogLevel level, string message, Exception? exception) + { + var entry = new LogEntry(level, message, exception); + + await Dispatcher.UIThread.InvokeAsync(() => + { + LogEntries.Add(entry); + + while (LogEntries.Count > MaxLogEntries) + { + LogEntries.RemoveAt(0); + } + + LogAdded?.Invoke(this, entry); + }); + } +} + +public record LogEntry(LogLevel Level, string Message, Exception? Exception) +{ + public DateTime Timestamp { get; } = DateTime.Now; +} + +public enum LogLevel +{ + Debug, + Info, + Warning, + Error +} diff --git a/src/ZonyLrcTools.Desktop/Services/DialogService.cs b/src/ZonyLrcTools.Desktop/Services/DialogService.cs new file mode 100644 index 0000000..5a030d9 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Services/DialogService.cs @@ -0,0 +1,60 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; + +namespace ZonyLrcTools.Desktop.Services; + +public class DialogService : IDialogService +{ + private Window? MainWindow => + (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)? + .MainWindow; + + public async Task ShowFolderPickerAsync(string title) + { + if (MainWindow == null) return null; + + var folders = await MainWindow.StorageProvider.OpenFolderPickerAsync( + new FolderPickerOpenOptions + { + Title = title, + AllowMultiple = false + }); + + return folders.FirstOrDefault()?.Path.LocalPath; + } + + public async Task ShowFilePickerAsync(string title, string[]? filters = null) + { + if (MainWindow == null) return null; + + var fileTypes = filters?.Select(f => new FilePickerFileType(f) + { + Patterns = new[] { f } + }).ToList(); + + var files = await MainWindow.StorageProvider.OpenFilePickerAsync( + new FilePickerOpenOptions + { + Title = title, + AllowMultiple = false, + FileTypeFilter = fileTypes + }); + + return files.FirstOrDefault()?.Path.LocalPath; + } + + public async Task ShowConfirmDialogAsync(string title, string message) + { + // TODO: Implement custom confirm dialog + await Task.CompletedTask; + return true; + } + + public async Task ShowMessageAsync(string title, string message) + { + // TODO: Implement custom message dialog + await Task.CompletedTask; + } +} diff --git a/src/ZonyLrcTools.Desktop/Services/IDialogService.cs b/src/ZonyLrcTools.Desktop/Services/IDialogService.cs new file mode 100644 index 0000000..0428216 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Services/IDialogService.cs @@ -0,0 +1,9 @@ +namespace ZonyLrcTools.Desktop.Services; + +public interface IDialogService +{ + Task ShowFolderPickerAsync(string title); + Task ShowFilePickerAsync(string title, string[]? filters = null); + Task ShowConfirmDialogAsync(string title, string message); + Task ShowMessageAsync(string title, string message); +} diff --git a/src/ZonyLrcTools.Desktop/Services/INavigationService.cs b/src/ZonyLrcTools.Desktop/Services/INavigationService.cs new file mode 100644 index 0000000..f3697f0 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Services/INavigationService.cs @@ -0,0 +1,13 @@ +using ZonyLrcTools.Desktop.ViewModels; + +namespace ZonyLrcTools.Desktop.Services; + +public interface INavigationService +{ + ViewModelBase? CurrentViewModel { get; } + event EventHandler? NavigationChanged; + + TViewModel NavigateTo() where TViewModel : ViewModelBase; + void GoBack(); + bool CanGoBack { get; } +} diff --git a/src/ZonyLrcTools.Desktop/Services/IThemeService.cs b/src/ZonyLrcTools.Desktop/Services/IThemeService.cs new file mode 100644 index 0000000..03f14d3 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Services/IThemeService.cs @@ -0,0 +1,12 @@ +using Avalonia.Styling; + +namespace ZonyLrcTools.Desktop.Services; + +public interface IThemeService +{ + ThemeVariant CurrentTheme { get; } + event EventHandler? ThemeChanged; + + void SetTheme(ThemeVariant theme); + void ToggleTheme(); +} diff --git a/src/ZonyLrcTools.Desktop/Services/NavigationService.cs b/src/ZonyLrcTools.Desktop/Services/NavigationService.cs new file mode 100644 index 0000000..57fa5e2 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Services/NavigationService.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.DependencyInjection; +using ZonyLrcTools.Desktop.ViewModels; + +namespace ZonyLrcTools.Desktop.Services; + +public class NavigationService : INavigationService +{ + private readonly IServiceProvider _serviceProvider; + private readonly Stack _navigationStack = new(); + + public ViewModelBase? CurrentViewModel => _navigationStack.TryPeek(out var vm) ? vm : null; + public bool CanGoBack => _navigationStack.Count > 1; + + public event EventHandler? NavigationChanged; + + public NavigationService(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public TViewModel NavigateTo() where TViewModel : ViewModelBase + { + var viewModel = _serviceProvider.GetRequiredService(); + + if (_navigationStack.TryPeek(out var current)) + { + _ = current.OnDeactivatedAsync(); + } + + _navigationStack.Push(viewModel); + _ = viewModel.OnActivatedAsync(); + + NavigationChanged?.Invoke(this, viewModel); + return viewModel; + } + + public void GoBack() + { + if (!CanGoBack) return; + + var current = _navigationStack.Pop(); + _ = current.OnDeactivatedAsync(); + + if (_navigationStack.TryPeek(out var previous)) + { + _ = previous.OnActivatedAsync(); + NavigationChanged?.Invoke(this, previous); + } + } +} diff --git a/src/ZonyLrcTools.Desktop/Services/ServiceCollectionExtensions.cs b/src/ZonyLrcTools.Desktop/Services/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..122768e --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Services/ServiceCollectionExtensions.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using ZonyLrcTools.Common.Infrastructure.DependencyInject; +using ZonyLrcTools.Common.Infrastructure.Logging; +using ZonyLrcTools.Common.Infrastructure.Network; +using ZonyLrcTools.Desktop.Infrastructure.Localization; +using ZonyLrcTools.Desktop.ViewModels; + +namespace ZonyLrcTools.Desktop.Services; + +public static class ServiceCollectionExtensions +{ + /// + /// Register all services for the desktop application. + /// + public static IServiceCollection AddDesktopServices(this IServiceCollection services) + { + // Register Common project's auto dependency injection + services.BeginAutoDependencyInject(); + services.BeginAutoDependencyInject(); + + // Configure Common services + services.ConfigureConfiguration(); + services.ConfigureLocalization(); + services.ConfigureToolService(); + + // Configure Desktop localization + // Note: Don't set ResourcesPath here because the embedded resource name is + // ZonyLrcTools.Desktop.UIStrings (not ZonyLrcTools.Desktop.Resources.UIStrings) + services.AddLocalization(); + services.AddSingleton(); + + // Register GUI-specific IWarpLogger implementation + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + // Register navigation and dialog services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register ViewModels + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services; + } + + /// + /// Initialize language settings based on system culture or user preference. + /// + public static void InitializeLanguage(string? preferredCulture = null) + { + CultureInfo culture; + + if (!string.IsNullOrEmpty(preferredCulture)) + { + // Use user's preferred culture + culture = new CultureInfo(preferredCulture); + } + else + { + // Use system culture, fallback to zh-CN if not supported + var systemCulture = CultureInfo.CurrentUICulture.Name; + culture = systemCulture.StartsWith("zh") ? new CultureInfo("zh-CN") : + systemCulture.StartsWith("en") ? new CultureInfo("en-US") : + new CultureInfo("zh-CN"); + } + + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + } +} diff --git a/src/ZonyLrcTools.Desktop/Services/ThemeService.cs b/src/ZonyLrcTools.Desktop/Services/ThemeService.cs new file mode 100644 index 0000000..d8b1a57 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Services/ThemeService.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Styling; + +namespace ZonyLrcTools.Desktop.Services; + +public class ThemeService : IThemeService +{ + public ThemeVariant CurrentTheme => Application.Current?.ActualThemeVariant ?? ThemeVariant.Default; + + public event EventHandler? ThemeChanged; + + public void SetTheme(ThemeVariant theme) + { + if (Application.Current == null) return; + + Application.Current.RequestedThemeVariant = theme; + ThemeChanged?.Invoke(this, theme); + } + + public void ToggleTheme() + { + var newTheme = CurrentTheme == ThemeVariant.Dark + ? ThemeVariant.Light + : ThemeVariant.Dark; + + SetTheme(newTheme); + } +} diff --git a/src/ZonyLrcTools.Desktop/ViewModels/AlbumDownloadViewModel.cs b/src/ZonyLrcTools.Desktop/ViewModels/AlbumDownloadViewModel.cs new file mode 100644 index 0000000..688b2b1 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/ViewModels/AlbumDownloadViewModel.cs @@ -0,0 +1,137 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ZonyLrcTools.Common.Album; +using ZonyLrcTools.Desktop.Infrastructure.Localization; +using ZonyLrcTools.Desktop.Services; + +namespace ZonyLrcTools.Desktop.ViewModels; + +public partial class AlbumDownloadViewModel : ViewModelBase +{ + private readonly IAlbumDownloader _albumDownloader; + private readonly IDialogService _dialogService; + private readonly IUILocalizationService? _localization; + + private CancellationTokenSource? _downloadCts; + + [ObservableProperty] + private string? _selectedFolderPath; + + [ObservableProperty] + private int _parallelCount = 2; + + [ObservableProperty] + private int _totalCount; + + [ObservableProperty] + private int _completedCount; + + [ObservableProperty] + private int _failedCount; + + [ObservableProperty] + private double _progressPercentage; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanStartDownload))] + private bool _isDownloading; + + // Localized strings + public string AlbumTitle => _localization?["Album_Title"] ?? "Album Cover Download"; + public string AlbumDescription => _localization?["Album_Description"] ?? "Download album artwork for your music collection"; + public string AlbumSelectFolder => _localization?["Album_SelectFolder"] ?? "Select music folder..."; + public string AlbumBrowse => _localization?["Album_Browse"] ?? "Browse..."; + public string AlbumParallel => _localization?["Album_Parallel"] ?? "Parallel:"; + public string AlbumStartDownload => _localization?["Album_StartDownload"] ?? "Start Download"; + public string AlbumStopDownload => _localization?["Album_StopDownload"] ?? "Stop Download"; + public string CommonTotal => _localization?["Common_Total"] ?? "Total:"; + public string CommonFiles => _localization?["Common_Files"] ?? "files"; + public string CommonSuccess => _localization?["Common_Success"] ?? "Success:"; + public string CommonFailed => _localization?["Common_Failed"] ?? "Failed:"; + public string ColumnSongName => _localization?["Column_SongName"] ?? "Song Name"; + public string ColumnArtist => _localization?["Column_Artist"] ?? "Artist"; + public string ColumnStatus => _localization?["Column_Status"] ?? "Status"; + + public bool CanStartDownload => !IsDownloading && !string.IsNullOrEmpty(SelectedFolderPath); + + public ObservableCollection MusicFiles { get; } = new(); + + public AlbumDownloadViewModel( + IAlbumDownloader albumDownloader, + IDialogService dialogService, + IUILocalizationService? localization = null) + { + _albumDownloader = albumDownloader; + _dialogService = dialogService; + _localization = localization; + + if (_localization != null) + { + _localization.LanguageChanged += OnLanguageChanged; + } + } + + private void OnLanguageChanged(object? sender, EventArgs e) + { + OnPropertyChanged(nameof(AlbumTitle)); + OnPropertyChanged(nameof(AlbumDescription)); + OnPropertyChanged(nameof(AlbumSelectFolder)); + OnPropertyChanged(nameof(AlbumBrowse)); + OnPropertyChanged(nameof(AlbumParallel)); + OnPropertyChanged(nameof(AlbumStartDownload)); + OnPropertyChanged(nameof(AlbumStopDownload)); + OnPropertyChanged(nameof(CommonTotal)); + OnPropertyChanged(nameof(CommonFiles)); + OnPropertyChanged(nameof(CommonSuccess)); + OnPropertyChanged(nameof(CommonFailed)); + OnPropertyChanged(nameof(ColumnSongName)); + OnPropertyChanged(nameof(ColumnArtist)); + OnPropertyChanged(nameof(ColumnStatus)); + } + + [RelayCommand] + private async Task SelectFolderAsync() + { + var folder = await _dialogService.ShowFolderPickerAsync("Select Music Folder"); + if (string.IsNullOrEmpty(folder)) return; + + SelectedFolderPath = folder; + OnPropertyChanged(nameof(CanStartDownload)); + } + + [RelayCommand] + private async Task StartDownloadAsync() + { + if (string.IsNullOrEmpty(SelectedFolderPath)) return; + + IsDownloading = true; + CompletedCount = 0; + FailedCount = 0; + ProgressPercentage = 0; + + _downloadCts = new CancellationTokenSource(); + + try + { + // TODO: Implement actual download logic using _albumDownloader + await Task.Delay(1000, _downloadCts.Token); // Placeholder + } + catch (OperationCanceledException) + { + // User cancelled + } + finally + { + IsDownloading = false; + _downloadCts?.Dispose(); + _downloadCts = null; + } + } + + [RelayCommand] + private void CancelDownload() + { + _downloadCts?.Cancel(); + } +} diff --git a/src/ZonyLrcTools.Desktop/ViewModels/HomeViewModel.cs b/src/ZonyLrcTools.Desktop/ViewModels/HomeViewModel.cs new file mode 100644 index 0000000..197da45 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/ViewModels/HomeViewModel.cs @@ -0,0 +1,15 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ZonyLrcTools.Desktop.ViewModels; + +public partial class HomeViewModel : ViewModelBase +{ + [ObservableProperty] + private string _welcomeMessage = "Welcome to ZonyLrcTools X"; + + [ObservableProperty] + private string _description = "A cross-platform tool for downloading lyrics and album covers for your music collection."; + + [ObservableProperty] + private string _version = "4.0.3"; +} diff --git a/src/ZonyLrcTools.Desktop/ViewModels/LyricsDownloadViewModel.cs b/src/ZonyLrcTools.Desktop/ViewModels/LyricsDownloadViewModel.cs new file mode 100644 index 0000000..ac0412e --- /dev/null +++ b/src/ZonyLrcTools.Desktop/ViewModels/LyricsDownloadViewModel.cs @@ -0,0 +1,164 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ZonyLrcTools.Common.Lyrics; +using ZonyLrcTools.Desktop.Infrastructure.Localization; +using ZonyLrcTools.Desktop.Services; + +namespace ZonyLrcTools.Desktop.ViewModels; + +public partial class LyricsDownloadViewModel : ViewModelBase +{ + private readonly ILyricsDownloader _lyricsDownloader; + private readonly IDialogService _dialogService; + private readonly IUILocalizationService? _localization; + + private CancellationTokenSource? _downloadCts; + + [ObservableProperty] + private string? _selectedFolderPath; + + [ObservableProperty] + private int _parallelCount = 2; + + [ObservableProperty] + private int _totalCount; + + [ObservableProperty] + private int _completedCount; + + [ObservableProperty] + private int _failedCount; + + [ObservableProperty] + private double _progressPercentage; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(StartDownloadCommand))] + [NotifyPropertyChangedFor(nameof(CanStartDownload))] + private bool _isDownloading; + + [ObservableProperty] + private string? _currentProcessingFile; + + // Localized strings + public string LyricsTitle => _localization?["Lyrics_Title"] ?? "Lyrics Download"; + public string LyricsDescription => _localization?["Lyrics_Description"] ?? "Batch download lyrics for your music files"; + public string LyricsSelectFolder => _localization?["Lyrics_SelectFolder"] ?? "Select music folder..."; + public string LyricsBrowse => _localization?["Lyrics_Browse"] ?? "Browse..."; + public string LyricsParallel => _localization?["Lyrics_Parallel"] ?? "Parallel:"; + public string LyricsStartDownload => _localization?["Lyrics_StartDownload"] ?? "Start Download"; + public string LyricsStopDownload => _localization?["Lyrics_StopDownload"] ?? "Stop Download"; + public string CommonTotal => _localization?["Common_Total"] ?? "Total:"; + public string CommonFiles => _localization?["Common_Files"] ?? "files"; + public string CommonSuccess => _localization?["Common_Success"] ?? "Success:"; + public string CommonFailed => _localization?["Common_Failed"] ?? "Failed:"; + public string ColumnSongName => _localization?["Column_SongName"] ?? "Song Name"; + public string ColumnArtist => _localization?["Column_Artist"] ?? "Artist"; + public string ColumnFilePath => _localization?["Column_FilePath"] ?? "File Path"; + public string ColumnStatus => _localization?["Column_Status"] ?? "Status"; + + public bool CanStartDownload => !IsDownloading && !string.IsNullOrEmpty(SelectedFolderPath); + + public ObservableCollection MusicFiles { get; } = new(); + + public LyricsDownloadViewModel( + ILyricsDownloader lyricsDownloader, + IDialogService dialogService, + IUILocalizationService? localization = null) + { + _lyricsDownloader = lyricsDownloader; + _dialogService = dialogService; + _localization = localization; + + if (_localization != null) + { + _localization.LanguageChanged += OnLanguageChanged; + } + } + + private void OnLanguageChanged(object? sender, EventArgs e) + { + OnPropertyChanged(nameof(LyricsTitle)); + OnPropertyChanged(nameof(LyricsDescription)); + OnPropertyChanged(nameof(LyricsSelectFolder)); + OnPropertyChanged(nameof(LyricsBrowse)); + OnPropertyChanged(nameof(LyricsParallel)); + OnPropertyChanged(nameof(LyricsStartDownload)); + OnPropertyChanged(nameof(LyricsStopDownload)); + OnPropertyChanged(nameof(CommonTotal)); + OnPropertyChanged(nameof(CommonFiles)); + OnPropertyChanged(nameof(CommonSuccess)); + OnPropertyChanged(nameof(CommonFailed)); + OnPropertyChanged(nameof(ColumnSongName)); + OnPropertyChanged(nameof(ColumnArtist)); + OnPropertyChanged(nameof(ColumnFilePath)); + OnPropertyChanged(nameof(ColumnStatus)); + } + + [RelayCommand] + private async Task SelectFolderAsync() + { + var folder = await _dialogService.ShowFolderPickerAsync("Select Music Folder"); + if (string.IsNullOrEmpty(folder)) return; + + SelectedFolderPath = folder; + OnPropertyChanged(nameof(CanStartDownload)); + } + + [RelayCommand(CanExecute = nameof(CanStartDownload))] + private async Task StartDownloadAsync() + { + if (string.IsNullOrEmpty(SelectedFolderPath)) return; + + IsDownloading = true; + CompletedCount = 0; + FailedCount = 0; + ProgressPercentage = 0; + + _downloadCts = new CancellationTokenSource(); + + try + { + // TODO: Implement actual download logic using _lyricsDownloader + await Task.Delay(1000, _downloadCts.Token); // Placeholder + } + catch (OperationCanceledException) + { + // User cancelled + } + finally + { + IsDownloading = false; + _downloadCts?.Dispose(); + _downloadCts = null; + } + } + + [RelayCommand] + private void CancelDownload() + { + _downloadCts?.Cancel(); + } +} + +public partial class MusicFileViewModel : ObservableObject +{ + [ObservableProperty] + private string _filePath = string.Empty; + + [ObservableProperty] + private string _name = string.Empty; + + [ObservableProperty] + private string _artist = string.Empty; + + [ObservableProperty] + private bool _isProcessed; + + [ObservableProperty] + private bool _isSuccessful; + + [ObservableProperty] + private string? _statusMessage; +} diff --git a/src/ZonyLrcTools.Desktop/ViewModels/MainWindowViewModel.cs b/src/ZonyLrcTools.Desktop/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..479dec8 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,72 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ZonyLrcTools.Desktop.Infrastructure.Localization; +using ZonyLrcTools.Desktop.Services; + +namespace ZonyLrcTools.Desktop.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + private readonly IThemeService _themeService; + private readonly IUILocalizationService _localization; + + [ObservableProperty] + private ViewModelBase? _currentPage; + + [ObservableProperty] + private int _selectedNavigationIndex; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ThemeButtonText))] + private bool _isDarkTheme; + + [ObservableProperty] + private string _title = "ZonyLrcTools X"; + + // Navigation localized strings + public string NavHome => _localization["Nav_Home"]; + public string NavLyricsDownload => _localization["Nav_LyricsDownload"]; + public string NavAlbumDownload => _localization["Nav_AlbumDownload"]; + public string NavSettings => _localization["Nav_Settings"]; + public string HomeDescription => _localization["Home_Description"]; + + public string ThemeButtonText => IsDarkTheme + ? _localization["Settings_ThemeLight"] + : _localization["Settings_ThemeDark"]; + + public MainWindowViewModel( + IThemeService themeService, + IUILocalizationService localization, + HomeViewModel homeViewModel) + { + _themeService = themeService; + _localization = localization; + + // Subscribe to language changes + _localization.LanguageChanged += OnLanguageChanged; + + // Set initial page + CurrentPage = homeViewModel; + SelectedNavigationIndex = 0; + } + + private void OnLanguageChanged(object? sender, EventArgs e) + { + // Notify all localized properties have changed + OnPropertyChanged(nameof(NavHome)); + OnPropertyChanged(nameof(NavLyricsDownload)); + OnPropertyChanged(nameof(NavAlbumDownload)); + OnPropertyChanged(nameof(NavSettings)); + OnPropertyChanged(nameof(HomeDescription)); + OnPropertyChanged(nameof(ThemeButtonText)); + } + + [RelayCommand] + private void ToggleTheme() + { + IsDarkTheme = !IsDarkTheme; + _themeService.SetTheme(IsDarkTheme + ? Avalonia.Styling.ThemeVariant.Dark + : Avalonia.Styling.ThemeVariant.Light); + } +} diff --git a/src/ZonyLrcTools.Desktop/ViewModels/SettingsViewModel.cs b/src/ZonyLrcTools.Desktop/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..9a4d70e --- /dev/null +++ b/src/ZonyLrcTools.Desktop/ViewModels/SettingsViewModel.cs @@ -0,0 +1,253 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Options; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; +using ZonyLrcTools.Common.Configuration; +using ZonyLrcTools.Desktop.Infrastructure.Localization; + +namespace ZonyLrcTools.Desktop.ViewModels; + +public partial class SettingsViewModel : ViewModelBase +{ + private readonly GlobalOptions _globalOptions; + private readonly IUILocalizationService? _localizationService; + private bool _isLoading = true; // Prevent saving during initial load + + [ObservableProperty] + private bool _followSystemLanguage = true; + + [ObservableProperty] + private bool _isChineseSelected = true; + + [ObservableProperty] + private bool _isEnglishSelected; + + [ObservableProperty] + private bool _isProxyEnabled; + + [ObservableProperty] + private string _proxyIp = "127.0.0.1"; + + [ObservableProperty] + private int _proxyPort = 7890; + + [ObservableProperty] + private bool _isTranslationEnabled; + + [ObservableProperty] + private bool _isOneLineMode; + + [ObservableProperty] + private bool _skipExistingLyrics; + + [ObservableProperty] + private string _selectedEncoding = "utf-8"; + + // Localized strings + public string SettingsTitle => _localizationService?["Settings_Title"] ?? "Settings"; + public string SettingsLanguage => _localizationService?["Settings_Language"] ?? "Language"; + public string SettingsFollowSystem => _localizationService?["Settings_FollowSystem"] ?? "Follow System"; + public string SettingsChinese => _localizationService?["Settings_Chinese"] ?? "Chinese"; + public string SettingsEnglish => _localizationService?["Settings_English"] ?? "English"; + public string SettingsProxy => _localizationService?["Settings_Proxy"] ?? "Proxy"; + public string SettingsProxyEnable => _localizationService?["Settings_ProxyEnable"] ?? "Enable Proxy"; + public string SettingsProxyAddress => _localizationService?["Settings_ProxyAddress"] ?? "Proxy Address"; + public string SettingsLyricsProvider => _localizationService?["Settings_LyricsProvider"] ?? "Lyrics Provider"; + public string SettingsReset => _localizationService?["Settings_Reset"] ?? "Reset"; + + public ObservableCollection AvailableEncodings { get; } = new() + { + "utf-8", + "utf-8-bom", + "gb2312", + "gbk" + }; + + public ObservableCollection LyricsProviders { get; } = new(); + + public SettingsViewModel(IOptions options, IUILocalizationService? localizationService = null) + { + _globalOptions = options.Value; + _localizationService = localizationService; + + // Subscribe to language changes + if (_localizationService != null) + { + _localizationService.LanguageChanged += OnLanguageChanged; + } + + LoadSettings(); + LoadLanguageSettings(); + _isLoading = false; // Allow saving after initial load + } + + private void OnLanguageChanged(object? sender, EventArgs e) + { + OnPropertyChanged(nameof(SettingsTitle)); + OnPropertyChanged(nameof(SettingsLanguage)); + OnPropertyChanged(nameof(SettingsFollowSystem)); + OnPropertyChanged(nameof(SettingsChinese)); + OnPropertyChanged(nameof(SettingsEnglish)); + OnPropertyChanged(nameof(SettingsProxy)); + OnPropertyChanged(nameof(SettingsProxyEnable)); + OnPropertyChanged(nameof(SettingsProxyAddress)); + OnPropertyChanged(nameof(SettingsLyricsProvider)); + OnPropertyChanged(nameof(SettingsReset)); + } + + private void LoadLanguageSettings() + { + var currentCulture = CultureInfo.CurrentUICulture.Name; + IsChineseSelected = currentCulture.StartsWith("zh"); + IsEnglishSelected = currentCulture.StartsWith("en"); + } + + partial void OnFollowSystemLanguageChanged(bool value) + { + if (value) + { + // Auto-detect system language + var systemCulture = CultureInfo.InstalledUICulture.Name; + ApplyLanguage(systemCulture.StartsWith("zh") ? "zh-CN" : "en-US"); + } + SaveSettingsIfNotLoading(); + } + + partial void OnIsChineseSelectedChanged(bool value) + { + if (value && !FollowSystemLanguage) + { + ApplyLanguage("zh-CN"); + } + SaveSettingsIfNotLoading(); + } + + partial void OnIsEnglishSelectedChanged(bool value) + { + if (value && !FollowSystemLanguage) + { + ApplyLanguage("en-US"); + } + SaveSettingsIfNotLoading(); + } + + partial void OnIsProxyEnabledChanged(bool value) => SaveSettingsIfNotLoading(); + partial void OnProxyIpChanged(string value) => SaveSettingsIfNotLoading(); + partial void OnProxyPortChanged(int value) => SaveSettingsIfNotLoading(); + partial void OnIsTranslationEnabledChanged(bool value) => SaveSettingsIfNotLoading(); + partial void OnIsOneLineModeChanged(bool value) => SaveSettingsIfNotLoading(); + partial void OnSkipExistingLyricsChanged(bool value) => SaveSettingsIfNotLoading(); + partial void OnSelectedEncodingChanged(string value) => SaveSettingsIfNotLoading(); + + private void ApplyLanguage(string cultureName) + { + _localizationService?.SetLanguage(cultureName); + } + + private void SaveSettingsIfNotLoading() + { + if (!_isLoading) + { + SaveSettingsToFile(); + } + } + + private void LoadSettings() + { + // Load network settings + IsProxyEnabled = _globalOptions.NetworkOptions?.IsEnable ?? false; + ProxyIp = _globalOptions.NetworkOptions?.Ip ?? "127.0.0.1"; + ProxyPort = _globalOptions.NetworkOptions?.Port ?? 7890; + + // Load lyrics settings + var lyricsConfig = _globalOptions.Provider?.Lyric?.Config; + if (lyricsConfig != null) + { + IsTranslationEnabled = lyricsConfig.IsEnableTranslation; + IsOneLineMode = lyricsConfig.IsOneLine; + SkipExistingLyrics = lyricsConfig.IsSkipExistLyricFiles; + SelectedEncoding = lyricsConfig.FileEncoding ?? "utf-8"; + } + + // Load lyrics providers + var providers = _globalOptions.Provider?.Lyric?.Plugin; + if (providers != null) + { + foreach (var provider in providers.OrderBy(p => p.Priority)) + { + LyricsProviders.Add(new LyricsProviderSettingViewModel + { + Name = provider.Name ?? "Unknown", + Priority = provider.Priority, + IsEnabled = provider.Priority != -1 + }); + } + } + } + + private void SaveSettingsToFile() + { + try + { + // Update GlobalOptions with current values + if (_globalOptions.NetworkOptions != null) + { + _globalOptions.NetworkOptions.IsEnable = IsProxyEnabled; + _globalOptions.NetworkOptions.Ip = ProxyIp; + _globalOptions.NetworkOptions.Port = ProxyPort; + } + + if (_globalOptions.Provider?.Lyric?.Config != null) + { + _globalOptions.Provider.Lyric.Config.IsEnableTranslation = IsTranslationEnabled; + _globalOptions.Provider.Lyric.Config.IsOneLine = IsOneLineMode; + _globalOptions.Provider.Lyric.Config.IsSkipExistLyricFiles = SkipExistingLyrics; + _globalOptions.Provider.Lyric.Config.FileEncoding = SelectedEncoding; + } + + // Serialize and save to file + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + var yaml = serializer.Serialize(_globalOptions); + var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.yaml"); + File.WriteAllText(configPath, yaml); + } + catch (Exception ex) + { + // Log error but don't crash - settings save is best effort + System.Diagnostics.Debug.WriteLine($"Failed to save settings: {ex.Message}"); + } + } + + [RelayCommand] + private void ResetToDefaults() + { + _isLoading = true; // Prevent multiple saves during reset + IsProxyEnabled = false; + ProxyIp = "127.0.0.1"; + ProxyPort = 7890; + IsTranslationEnabled = false; + IsOneLineMode = false; + SkipExistingLyrics = true; + SelectedEncoding = "utf-8"; + _isLoading = false; + SaveSettingsToFile(); // Save once after all resets + } +} + +public partial class LyricsProviderSettingViewModel : ObservableObject +{ + [ObservableProperty] + private string _name = string.Empty; + + [ObservableProperty] + private int _priority; + + [ObservableProperty] + private bool _isEnabled; +} diff --git a/src/ZonyLrcTools.Desktop/ViewModels/ViewModelBase.cs b/src/ZonyLrcTools.Desktop/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..df142a3 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/ViewModels/ViewModelBase.cs @@ -0,0 +1,28 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ZonyLrcTools.Desktop.ViewModels; + +/// +/// Base class for all ViewModels. +/// +public abstract partial class ViewModelBase : ObservableObject +{ + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsNotBusy))] + private bool _isBusy; + + [ObservableProperty] + private string? _busyMessage; + + public bool IsNotBusy => !IsBusy; + + /// + /// Called when the ViewModel is activated (navigated to). + /// + public virtual Task OnActivatedAsync() => Task.CompletedTask; + + /// + /// Called when the ViewModel is deactivated (navigated away). + /// + public virtual Task OnDeactivatedAsync() => Task.CompletedTask; +} diff --git a/src/ZonyLrcTools.Desktop/Views/MainWindow.axaml b/src/ZonyLrcTools.Desktop/Views/MainWindow.axaml new file mode 100644 index 0000000..d827c6f --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Views/MainWindow.axaml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ZonyLrcTools.Desktop/Views/Pages/HomePage.axaml.cs b/src/ZonyLrcTools.Desktop/Views/Pages/HomePage.axaml.cs new file mode 100644 index 0000000..4bdea57 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Views/Pages/HomePage.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace ZonyLrcTools.Desktop.Views.Pages; + +public partial class HomePage : UserControl +{ + public HomePage() + { + InitializeComponent(); + } +} diff --git a/src/ZonyLrcTools.Desktop/Views/Pages/LyricsDownloadPage.axaml b/src/ZonyLrcTools.Desktop/Views/Pages/LyricsDownloadPage.axaml new file mode 100644 index 0000000..19e11d4 --- /dev/null +++ b/src/ZonyLrcTools.Desktop/Views/Pages/LyricsDownloadPage.axaml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + +