From 3b3bf46097ecd8f06863d6f9f707c7d77553e8ef Mon Sep 17 00:00:00 2001 From: Oliver Wong Date: Wed, 10 Jun 2026 19:25:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=AC=E5=9C=B0=E5=8C=96=E6=A1=86?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +- src/GUI/App.axaml | 10 +- src/GUI/App.axaml.cs | 4 +- src/GUI/Assets/Locale.Designer.cs | 48 ------ src/GUI/Assets/Locale.resx | 21 --- src/GUI/Converters/LocalizeExtension.cs | 25 +++ src/GUI/Converters/ValueConverters.cs | 5 +- src/GUI/GUI.csproj | 17 +- src/GUI/Resources/LocaleEn.xml | 157 ++++++++++++++++++ src/GUI/Resources/LocaleZh.xml | 157 ++++++++++++++++++ src/GUI/Services/LocalizationService.cs | 168 ++++++++++++++++++++ src/GUI/ViewModels/PlayerBarViewModel.cs | 5 +- src/GUI/ViewModels/ScanProgressViewModel.cs | 17 +- src/GUI/ViewModels/ViewModelBase.cs | 2 + src/GUI/Views/FileListView.axaml | 11 +- src/GUI/Views/MainWindow.axaml | 11 +- src/GUI/Views/MainWindow.axaml.cs | 3 +- src/GUI/Views/MetadataPanelView.axaml | 55 ++++--- src/GUI/Views/ScanProgressView.axaml | 25 +-- src/GUI/Views/TagTreeView.axaml | 11 +- src/Resonance.sln.DotSettings.user | 6 +- 21 files changed, 617 insertions(+), 148 deletions(-) delete mode 100644 src/GUI/Assets/Locale.Designer.cs delete mode 100644 src/GUI/Assets/Locale.resx create mode 100644 src/GUI/Converters/LocalizeExtension.cs create mode 100644 src/GUI/Resources/LocaleEn.xml create mode 100644 src/GUI/Resources/LocaleZh.xml create mode 100644 src/GUI/Services/LocalizationService.cs diff --git a/README.md b/README.md index ce69f11..7a84a0f 100755 --- a/README.md +++ b/README.md @@ -17,12 +17,12 @@ ### Windows 数据库: %APPDATA%\Local\OCES\Resonance\Databases -偏好设置: %APPDATA%\Roaming\OCES\Resonance\ +偏好设置: %APPDATA%\Roaming\OCES\Resonance\preferences.json ### Linux 数据库: XDG_DATA_HOME -偏好设置: XDG_CONFIG_HOME +偏好设置: XDG_CONFIG_HOME/preferences.json 缓存数据: XDG_CACHE_HOME ### TODO @@ -30,7 +30,8 @@ - [ ] 扫描时如果报错,报错信息可能会填满整个窗口,导致Overlay无法关闭。 - [ ] 读取时如果报错,没有任何警告,会静默报错。需要有一个界面右下方的toast,或是发送系统通知告知用户遇到了错误。 - [x] 指针化Artwork字段。如果artwork的md5 -- [ ] 本地化框架 +- [x] 本地化框架 +- [ ] 偏好设置/控制面板 ## 技术栈 diff --git a/src/GUI/App.axaml b/src/GUI/App.axaml index e849f37..e222270 100644 --- a/src/GUI/App.axaml +++ b/src/GUI/App.axaml @@ -3,22 +3,22 @@ x:Class="GUI.App" Name="Resonance" xmlns:local="using:GUI" + xmlns:l10n="using:GUI.Converters" RequestedThemeVariant="Default"> - - + - - + + - \ No newline at end of file + diff --git a/src/GUI/App.axaml.cs b/src/GUI/App.axaml.cs index 45bfd43..78fed5f 100644 --- a/src/GUI/App.axaml.cs +++ b/src/GUI/App.axaml.cs @@ -1,9 +1,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Data.Core; -using Avalonia.Data.Core.Plugins; -using System.Linq; using Avalonia.Markup.Xaml; +using GUI.Services; using GUI.ViewModels; using GUI.Views; diff --git a/src/GUI/Assets/Locale.Designer.cs b/src/GUI/Assets/Locale.Designer.cs deleted file mode 100644 index 25b1099..0000000 --- a/src/GUI/Assets/Locale.Designer.cs +++ /dev/null @@ -1,48 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace GUI { - using System; - - - [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [System.Diagnostics.DebuggerNonUserCodeAttribute()] - [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Locale { - - private static System.Resources.ResourceManager resourceMan; - - private static System.Globalization.CultureInfo resourceCulture; - - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Locale() { - } - - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] - internal static System.Resources.ResourceManager ResourceManager { - get { - if (object.Equals(null, resourceMan)) { - System.Resources.ResourceManager temp = new System.Resources.ResourceManager("GUI.Locale", typeof(Locale).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] - internal static System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - } -} diff --git a/src/GUI/Assets/Locale.resx b/src/GUI/Assets/Locale.resx deleted file mode 100644 index a4c5284..0000000 --- a/src/GUI/Assets/Locale.resx +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - text/microsoft-resx - - - 1.3 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/src/GUI/Converters/LocalizeExtension.cs b/src/GUI/Converters/LocalizeExtension.cs new file mode 100644 index 0000000..e3a0ed3 --- /dev/null +++ b/src/GUI/Converters/LocalizeExtension.cs @@ -0,0 +1,25 @@ +using Avalonia.Data; +using Avalonia.Markup.Xaml; +using GUI.Services; + +namespace GUI.Converters; + +public class LocalizeExtension : MarkupExtension +{ + public string Key { get; set; } + + public LocalizeExtension(string key) + { + Key = key; + } + + public override object ProvideValue(IServiceProvider serviceProvider) + { + return new Binding + { + Source = LocalizationService.Instance, + Path = $"[{Key}]", + Mode = BindingMode.OneWay, + }; + } +} diff --git a/src/GUI/Converters/ValueConverters.cs b/src/GUI/Converters/ValueConverters.cs index 1e3b729..2d2b08c 100644 --- a/src/GUI/Converters/ValueConverters.cs +++ b/src/GUI/Converters/ValueConverters.cs @@ -1,5 +1,6 @@ using System.Globalization; using Avalonia.Data.Converters; +using GUI.Services; namespace GUI.Converters; @@ -36,8 +37,8 @@ public class ChannelsFormatConverter : IValueConverter { return value switch { - int ch when ch == 1 => "Mono", - int ch when ch == 2 => "Stereo", + int ch when ch == 1 => LocalizationService.Instance["ChannelsMono"], + int ch when ch == 2 => LocalizationService.Instance["ChannelsStereo"], null => "--", int ch => $"{ch}ch", _ => "--", diff --git a/src/GUI/GUI.csproj b/src/GUI/GUI.csproj index 36d9b4a..9e59251 100644 --- a/src/GUI/GUI.csproj +++ b/src/GUI/GUI.csproj @@ -10,6 +10,7 @@ + @@ -32,17 +33,11 @@ - - ResXFileCodeGenerator - Locale.Designer.cs + + GUI.LocaleZh.xml + + + GUI.LocaleEn.xml - - - - True - True - Locale.resx - - diff --git a/src/GUI/Resources/LocaleEn.xml b/src/GUI/Resources/LocaleEn.xml new file mode 100644 index 0000000..fd6e4c1 --- /dev/null +++ b/src/GUI/Resources/LocaleEn.xml @@ -0,0 +1,157 @@ + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + About… + + + Preferences… + + + Database + + + Delete Unused Artworks + + + Open database file in Finder... + + + Scan Directory + + + Select Audio Directory + + + Search files... + + + Refresh + + + Reset Filters + + + No files yet. Scan a directory to add audio files. + + + Metadata + + + Save + + + Basic Info + + + Rating + + + Tags & Description + + + Category + + + Keywords (comma-separated) + + + Description + + + Notes + + + Duration: + + + Sample Rate: + + + Bit Depth: + + + Channels: + + + Format: + + + Select a file to view metadata + + + Enter category... + + + Enter keywords... + + + Enter description... + + + Enter notes... + + + Processed: + + + Added: {0} + + + Skipped: {0} + + + Close + + + Browse + + + All Files + + + Tags + + + Preparing to scan... + + + Discovering files... + + + Found {0} files, parsing metadata... + + + Scan complete: {0} added, {1} skipped + + + Scan error: {0} + + + No file selected + + + Mono + + + Stereo + + + Sort by + + diff --git a/src/GUI/Resources/LocaleZh.xml b/src/GUI/Resources/LocaleZh.xml new file mode 100644 index 0000000..924d574 --- /dev/null +++ b/src/GUI/Resources/LocaleZh.xml @@ -0,0 +1,157 @@ + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 关于… + + + 偏好设置… + + + 数据库 + + + 删除没有使用的封面 + + + 在访达中打开数据库文件… + + + 扫描目录 + + + 选择音频目录 + + + 搜索文件... + + + 刷新 + + + 重置筛选 + + + 暂无文件,请先扫描目录添加音频文件。 + + + 元数据 + + + 保存 + + + 基础信息 + + + 评分 + + + 标签与描述 + + + 分类 + + + 关键词(逗号分隔) + + + 描述 + + + 备注 + + + 时长: + + + 采样率: + + + 位深: + + + 声道: + + + 格式: + + + 选择文件查看元数据 + + + 输入分类... + + + 输入关键词... + + + 输入描述... + + + 输入备注... + + + 已处理: + + + 新增: {0} + + + 跳过: {0} + + + 关闭 + + + 浏览 + + + 全部文件 + + + 标签 + + + 准备扫描... + + + 正在发现文件... + + + 发现 {0} 个文件,正在解析元数据... + + + 扫描完成:新增 {0},跳过 {1} + + + 扫描出错:{0} + + + 未选择文件 + + + 单声道 + + + 立体声 + + + 排序字段 + + diff --git a/src/GUI/Services/LocalizationService.cs b/src/GUI/Services/LocalizationService.cs new file mode 100644 index 0000000..6408b07 --- /dev/null +++ b/src/GUI/Services/LocalizationService.cs @@ -0,0 +1,168 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Xml; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace GUI.Services; + +public sealed class LocalizationService : ObservableObject +{ + public static LocalizationService Instance { get; } = new(); + + private readonly Dictionary> _strings = new(); + + private CultureInfo _currentCulture = CultureInfo.GetCultureInfo("zh-CN"); + private string _currentCultureKey = "zh-CN"; + + public CultureInfo CurrentCulture + { + get => _currentCulture; + set + { + var key = value.Name; + if (key != "zh-CN" && key != "en-US") + key = "zh-CN"; + + if (!SetProperty(ref _currentCulture, CultureInfo.GetCultureInfo(key), nameof(CurrentCulture))) + return; + + _currentCultureKey = key; + CultureInfo.CurrentCulture = _currentCulture; + CultureInfo.CurrentUICulture = _currentCulture; + OnPropertyChanged(new PropertyChangedEventArgs("Item[]")); + } + } + + public IReadOnlyList SupportedCultures { get; } + + private LocalizationService() + { + LoadResources("zh-CN", "GUI.LocaleZh.xml"); + LoadResources("en-US", "GUI.LocaleEn.xml"); + + SupportedCultures = new ReadOnlyCollection( + [ + CultureInfo.GetCultureInfo("zh-CN"), + CultureInfo.GetCultureInfo("en-US"), + ]); + + LoadCulturePreference(); + } + + private void LoadResources(string cultureKey, string resourceName) + { + var dict = new Dictionary(); + var assembly = Assembly.GetExecutingAssembly(); + + using var stream = FindResource(assembly, resourceName); + if (stream == null) return; + + using var reader = XmlReader.Create(stream); + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element || reader.Name != "data") continue; + var name = reader.GetAttribute("name"); + reader.ReadToDescendant("value"); + var value = reader.ReadElementContentAsString(); + if (name != null) + dict[name] = value; + } + + _strings[cultureKey] = dict; + } + + private static Stream? FindResource(Assembly assembly, string resourceName) + { + var stream = assembly.GetManifestResourceStream(resourceName); + if (stream != null) return stream; + + foreach (var name in assembly.GetManifestResourceNames()) + { + if (name.EndsWith(resourceName, StringComparison.OrdinalIgnoreCase)) + return assembly.GetManifestResourceStream(name); + } + + return null; + } + + public string this[string key] + { + get + { + if (_strings.TryGetValue(_currentCultureKey, out var dict) && dict.TryGetValue(key, out var val)) + return val; + if (_currentCultureKey != "zh-CN" && _strings.TryGetValue("zh-CN", out var fallback) && fallback.TryGetValue(key, out var fb)) + return fb; + return $"<{key}>"; + } + } + + public string GetFormatted(string key, params object[] args) + { + try + { + return string.Format(_currentCulture, this[key], args); + } + catch + { + return this[key]; + } + } + + private void LoadCulturePreference() + { + var path = GetPreferencesPath(); + if (!File.Exists(path)) return; + + try + { + var json = File.ReadAllText(path); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("Language", out var lang)) + { + var langStr = lang.GetString(); + if (langStr is "zh-CN" or "en-US") + { + CurrentCulture = CultureInfo.GetCultureInfo(langStr); + } + } + } + catch + { + // ignore malformed preference file + } + } + + public void SaveCulturePreference() + { + var path = GetPreferencesPath(); + var dir = Path.GetDirectoryName(path); + if (dir != null) + Directory.CreateDirectory(dir); + + var opts = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(new Dictionary + { + ["Language"] = _currentCultureKey, + }, opts); + File.WriteAllText(path, json); + } + + private static string GetPreferencesPath() + { + if (OperatingSystem.IsMacOS()) + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", "Preferences", "com.oces.Resonance.json"); + if (OperatingSystem.IsWindows()) + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OCES", "Resonance", "preferences.json"); + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OCES", "Resonance", "preferences.json"); + } +} diff --git a/src/GUI/ViewModels/PlayerBarViewModel.cs b/src/GUI/ViewModels/PlayerBarViewModel.cs index 6a89e67..51b2e98 100644 --- a/src/GUI/ViewModels/PlayerBarViewModel.cs +++ b/src/GUI/ViewModels/PlayerBarViewModel.cs @@ -11,7 +11,7 @@ public partial class PlayerBarViewModel : ViewModelBase [ObservableProperty] private AudioFileMeta? _currentFile; [ObservableProperty] private bool _hasFile; - [ObservableProperty] private string _currentFileName = "未选择文件"; + [ObservableProperty] private string _currentFileName = string.Empty; [ObservableProperty] private bool _isPlaying; [ObservableProperty] private double _currentPosition; @@ -21,6 +21,7 @@ public partial class PlayerBarViewModel : ViewModelBase public PlayerBarViewModel(SyncChannel channel) { _channel = channel; + CurrentFileName = Loc["NoFileSelected"]; _channel.PropertyChanged += (_, e) => { @@ -39,7 +40,7 @@ public partial class PlayerBarViewModel : ViewModelBase if (file == null) { - CurrentFileName = "未选择文件"; + CurrentFileName = Loc["NoFileSelected"]; Duration = 0; HasFile = false; return; diff --git a/src/GUI/ViewModels/ScanProgressViewModel.cs b/src/GUI/ViewModels/ScanProgressViewModel.cs index 05b89da..b958ea5 100644 --- a/src/GUI/ViewModels/ScanProgressViewModel.cs +++ b/src/GUI/ViewModels/ScanProgressViewModel.cs @@ -15,6 +15,9 @@ public partial class ScanProgressViewModel : ViewModelBase [ObservableProperty] private double _progress; [ObservableProperty] private string _currentFile = string.Empty; + public string AddedText => Loc.GetFormatted("AddedFormat", AddedFiles); + public string SkippedText => Loc.GetFormatted("SkippedFormat", SkippedFiles); + public event Action? ScanCompleted; [RelayCommand] @@ -22,6 +25,7 @@ public partial class ScanProgressViewModel : ViewModelBase { if (string.IsNullOrWhiteSpace(directory)) return; + StatusText = Loc["ScanPreparing"]; Task.Run(() => ScanDirectoryInternal(directory)); } @@ -32,7 +36,7 @@ public partial class ScanProgressViewModel : ViewModelBase { IsScanning = false; HasError = false; - StatusText = "准备扫描..."; + StatusText = Loc["ScanPreparing"]; } private void ScanDirectoryInternal(string directory) @@ -41,7 +45,7 @@ public partial class ScanProgressViewModel : ViewModelBase { HasError = false; IsScanning = true; - StatusText = "正在发现文件..."; + StatusText = Loc["ScanDiscovering"]; Database.InitializeDatabase(); @@ -56,7 +60,7 @@ public partial class ScanProgressViewModel : ViewModelBase SkippedFiles = 0; Progress = 0; - StatusText = $"发现 {TotalFiles} 个文件,正在解析元数据..."; + StatusText = Loc.GetFormatted("ScanParsing", TotalFiles); foreach (string filePath in files) { @@ -100,12 +104,12 @@ public partial class ScanProgressViewModel : ViewModelBase UpdateProgress(); } - StatusText = $"扫描完成:新增 {AddedFiles},跳过 {SkippedFiles}"; + StatusText = Loc.GetFormatted("ScanComplete", AddedFiles, SkippedFiles); } catch (Exception ex) { HasError = true; - StatusText = $"扫描出错:{ex.Message}"; + StatusText = Loc.GetFormatted("ScanError", ex.Message); } finally { @@ -119,4 +123,7 @@ public partial class ScanProgressViewModel : ViewModelBase { Progress = TotalFiles > 0 ? (double)ProcessedFiles / TotalFiles * 100 : 0; } + + partial void OnAddedFilesChanged(int value) => OnPropertyChanged(nameof(AddedText)); + partial void OnSkippedFilesChanged(int value) => OnPropertyChanged(nameof(SkippedText)); } diff --git a/src/GUI/ViewModels/ViewModelBase.cs b/src/GUI/ViewModels/ViewModelBase.cs index 1abda89..0f00f01 100644 --- a/src/GUI/ViewModels/ViewModelBase.cs +++ b/src/GUI/ViewModels/ViewModelBase.cs @@ -1,7 +1,9 @@ using CommunityToolkit.Mvvm.ComponentModel; +using GUI.Services; namespace GUI.ViewModels; public abstract class ViewModelBase : ObservableObject { + public LocalizationService Loc => LocalizationService.Instance; } diff --git a/src/GUI/Views/FileListView.axaml b/src/GUI/Views/FileListView.axaml index a43200d..8f7a81d 100644 --- a/src/GUI/Views/FileListView.axaml +++ b/src/GUI/Views/FileListView.axaml @@ -5,6 +5,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:converters="using:GUI.Converters" + xmlns:l10n="using:GUI.Converters" mc:Ignorable="d" x:Class="GUI.Views.FileListView" x:DataType="vm:FileListViewModel"> @@ -25,22 +26,22 @@ BorderThickness="0,0,0,1"> - +