静态分析没有问题了。但是一尝试扫描文件夹就炸。怀疑是权限问题,但不确定。
This commit is contained in:
2026-05-26 14:14:11 +08:00
parent 0f73dfdb84
commit 87afe57bfa
23 changed files with 1251 additions and 7 deletions
+82
View File
@@ -464,5 +464,87 @@ public static class Database
return connection.ExecuteScalar<int>(sql, parameters); return connection.ExecuteScalar<int>(sql, parameters);
} }
/// <summary>
/// 获取所有唯一的分类值
/// </summary>
public static IEnumerable<string> GetAllCategories()
{
using IDbConnection connection = GetConnection();
connection.Open();
const string sql = "SELECT DISTINCT category FROM audio_files WHERE category IS NOT NULL AND category != '' ORDER BY category;";
return connection.Query<string>(sql);
}
/// <summary>
/// 解析 Keywords 字段,返回所有唯一的标签
/// </summary>
public static IEnumerable<string> GetAllKeywords()
{
using IDbConnection connection = GetConnection();
connection.Open();
const string sql = "SELECT keywords FROM audio_files WHERE keywords IS NOT NULL AND keywords != '';";
var rows = connection.Query<string>(sql);
var allKeywords = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
if (string.IsNullOrWhiteSpace(row)) continue;
foreach (var part in row.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (!string.IsNullOrWhiteSpace(part))
allKeywords.Add(part);
}
}
return allKeywords.OrderBy(k => k, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// 更新音频文件的可编辑字段
/// </summary>
public static bool UpdateEntry(AudioFileMeta meta)
{
try
{
using IDbConnection connection = GetConnection();
connection.Open();
const string sql = """
UPDATE audio_files SET
rating = @Rating,
notes = @Notes,
keywords = @Keywords,
category = @Category,
subcategory = @Subcategory,
description = @Description,
comments = @Comments,
fx_name = @FxName,
genre = @Genre,
style = @Style,
mood = @Mood,
library = @Library,
project_name = @ProjectName,
designer = @Designer,
recordist = @Recordist,
publisher = @Publisher,
manufacturer = @Manufacturer,
microphone = @Microphone,
location = @Location,
user1 = @User1,
user2 = @User2,
user3 = @User3,
user4 = @User4,
user5 = @User5,
user6 = @User6,
user7 = @User7,
user8 = @User8
WHERE id = @Id;
""";
connection.Execute(sql, meta);
return true;
}
catch (Exception)
{
return false;
}
}
#endregion #endregion
} }
+112
View File
@@ -0,0 +1,112 @@
using System.Globalization;
using Avalonia.Data.Converters;
namespace GUI.Converters;
public class DurationFormatConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not double duration) return "--:--";
var ts = TimeSpan.FromSeconds(duration);
if (ts.TotalHours >= 1)
return ts.ToString(@"h\:mm\:ss\.ff");
return ts.ToString(@"m\:ss\.ff");
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class SampleRateFormatConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not double rate) return "--";
return $"{rate / 1000.0:F1}k";
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class ChannelsFormatConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return value switch
{
int ch when ch == 1 => "Mono",
int ch when ch == 2 => "Stereo",
null => "--",
int ch => $"{ch}ch",
_ => "--"
};
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class RatingStarsConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not uint rating || rating == 0) return "☆☆☆☆☆";
return new string('★', (int)rating) + new string('☆', 5 - (int)rating);
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class DurationToSliderMaxConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not double duration) return 1.0;
return Math.Ceiling(duration);
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class PositionFormatConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not double pos) return "0:00";
var ts = TimeSpan.FromSeconds(pos);
return ts.ToString(@"m\:ss");
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public class InverseBoolConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool b) return !b;
return true;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is bool b) return !b;
return true;
}
}
public static class ValueConverters
{
public static readonly IValueConverter DurationFormat = new DurationFormatConverter();
public static readonly IValueConverter SampleRateFormat = new SampleRateFormatConverter();
public static readonly IValueConverter ChannelsFormat = new ChannelsFormatConverter();
public static readonly IValueConverter RatingStars = new RatingStarsConverter();
public static readonly IValueConverter DurationToSliderMax = new DurationToSliderMaxConverter();
public static readonly IValueConverter PositionFormat = new PositionFormatConverter();
public static readonly IValueConverter InverseBool = new InverseBoolConverter();
}
+1
View File
@@ -3,6 +3,7 @@
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
+26
View File
@@ -0,0 +1,26 @@
using CommunityToolkit.Mvvm.ComponentModel;
using OCES.Resonance.Core;
namespace GUI.Services;
public partial class SyncChannel : ObservableObject
{
public string Id { get; }
[ObservableProperty]
private AudioFileMeta? _selectedFile;
[ObservableProperty]
private string? _filterCategory;
[ObservableProperty]
private string? _filterKeyword;
[ObservableProperty]
private string _searchText = string.Empty;
public SyncChannel(string id)
{
Id = id;
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace GUI.Services;
public class SyncChannelManager
{
private readonly Dictionary<string, SyncChannel> _channels = new();
public SyncChannel GetOrCreate(string id)
{
if (!_channels.TryGetValue(id, out var channel))
{
channel = new SyncChannel(id);
_channels[id] = channel;
}
return channel;
}
public SyncChannel Default => GetOrCreate("default");
}
+118
View File
@@ -0,0 +1,118 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using GUI.Services;
using OCES.Resonance.Core;
namespace GUI.ViewModels;
public partial class FileListViewModel : ViewModelBase
{
private readonly SyncChannel _channel;
public ObservableCollection<AudioFileMeta> Files { get; } = new();
[ObservableProperty] private AudioFileMeta? _selectedFile;
[ObservableProperty] private string _searchText = string.Empty;
[ObservableProperty] private string? _selectedSortBy = "date_added";
[ObservableProperty] private bool _sortDescending = true;
[ObservableProperty] private double? _filterMinDuration;
[ObservableProperty] private double? _filterMaxDuration;
[ObservableProperty] private int? _filterSampleRate;
[ObservableProperty] private int? _filterChannels;
[ObservableProperty] private bool _isLoading;
public string[] SortOptions { get; } =
[
"date_added", "filename", "duration", "sample_rate", "channels", "rating", "type", "category"
];
public FileListViewModel(SyncChannel channel)
{
_channel = channel;
_channel.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(SyncChannel.FilterCategory)
or nameof(SyncChannel.FilterKeyword)
or nameof(SyncChannel.SearchText))
{
SearchText = _channel.SearchText;
RefreshFiles();
}
};
}
partial void OnSelectedFileChanged(AudioFileMeta? value)
{
_channel.SelectedFile = value;
}
[RelayCommand]
private void RefreshFiles()
{
try
{
IsLoading = true;
Files.Clear();
var entries = Database.QueryEntries(
searchText: string.IsNullOrWhiteSpace(_channel.SearchText) ? null : _channel.SearchText,
category: _channel.FilterCategory,
sortBy: SelectedSortBy,
sortDescending: SortDescending,
minDuration: FilterMinDuration,
maxDuration: FilterMaxDuration,
sampleRate: FilterSampleRate,
channels: FilterChannels,
limit: 500
);
// 如果有 FilterKeyword,在客户端过滤(Keywords 字段 LIKE 搜索)
foreach (var entry in entries)
{
if (!string.IsNullOrWhiteSpace(_channel.FilterKeyword))
{
if (entry.Keywords == null ||
!entry.Keywords.Split(',', StringSplitOptions.TrimEntries)
.Any(k => k.Equals(_channel.FilterKeyword, StringComparison.OrdinalIgnoreCase)))
continue;
}
Files.Add(entry);
}
IsLoading = false;
}
catch (Exception)
{
IsLoading = false;
}
}
[RelayCommand]
private void ApplyFilters()
{
RefreshFiles();
}
[RelayCommand]
private void ResetFilters()
{
FilterMinDuration = null;
FilterMaxDuration = null;
FilterSampleRate = null;
FilterChannels = null;
_channel.SearchText = string.Empty;
SearchText = string.Empty;
RefreshFiles();
}
partial void OnSearchTextChanged(string value)
{
_channel.SearchText = value;
}
}
+59 -2
View File
@@ -1,6 +1,63 @@
namespace GUI.ViewModels; using CommunityToolkit.Mvvm.ComponentModel;
using GUI.Services;
namespace GUI.ViewModels;
public partial class MainWindowViewModel : ViewModelBase public partial class MainWindowViewModel : ViewModelBase
{ {
public string Greeting { get; } = "Welcome to Avalonia!"; private readonly SyncChannelManager _channelManager;
private readonly SyncChannel _channel;
public TagTreeViewModel TagTreeVM { get; }
public FileListViewModel FileListVM { get; }
public MetadataPanelViewModel MetadataPanelVM { get; }
public PlayerBarViewModel PlayerBarVM { get; }
public ScanProgressViewModel ScanProgressVM { get; }
[ObservableProperty] private bool _isScanning;
public MainWindowViewModel()
{
_channelManager = new SyncChannelManager();
_channel = _channelManager.Default;
TagTreeVM = new TagTreeViewModel(_channel);
FileListVM = new FileListViewModel(_channel);
MetadataPanelVM = new MetadataPanelViewModel(_channel);
PlayerBarVM = new PlayerBarViewModel(_channel);
ScanProgressVM = new ScanProgressViewModel();
ScanProgressVM.ScanCompleted += OnScanCompleted;
// 启动时加载已有数据
LoadExistingData();
}
private void LoadExistingData()
{
try
{
OCES.Resonance.Core.Database.InitializeDatabase();
TagTreeVM.LoadDataCommand.Execute(null);
FileListVM.RefreshFilesCommand.Execute(null);
}
catch (Exception)
{
// 数据库尚未初始化或暂无数据,静默处理
}
}
private void OnScanCompleted()
{
IsScanning = false;
TagTreeVM.LoadDataCommand.Execute(null);
FileListVM.RefreshFilesCommand.Execute(null);
}
public void RequestScan(string directory)
{
if (string.IsNullOrWhiteSpace(directory)) return;
IsScanning = true;
ScanProgressVM.StartScanCommand.Execute(directory);
}
} }
@@ -0,0 +1,113 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using GUI.Services;
using OCES.Resonance.Core;
namespace GUI.ViewModels;
public partial class MetadataPanelViewModel : ViewModelBase
{
private readonly SyncChannel _channel;
[ObservableProperty] private AudioFileMeta? _currentFile;
[ObservableProperty] private bool _hasFile;
[ObservableProperty] private string _keywordsText = string.Empty;
[ObservableProperty] private string _notesText = string.Empty;
[ObservableProperty] private string _categoryText = string.Empty;
[ObservableProperty] private string _descriptionText = string.Empty;
[ObservableProperty] private uint _rating;
[ObservableProperty] private bool _isDirty;
public MetadataPanelViewModel(SyncChannel channel)
{
_channel = channel;
_channel.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(SyncChannel.SelectedFile))
{
LoadFile(_channel.SelectedFile);
}
};
}
private void LoadFile(AudioFileMeta? file)
{
CurrentFile = file;
if (file == null)
{
KeywordsText = string.Empty;
NotesText = string.Empty;
CategoryText = string.Empty;
DescriptionText = string.Empty;
Rating = 0;
IsDirty = false;
HasFile = false;
return;
}
KeywordsText = file.Keywords ?? string.Empty;
NotesText = file.Notes ?? string.Empty;
CategoryText = file.Category ?? string.Empty;
DescriptionText = file.Description ?? string.Empty;
Rating = file.Rating ?? 0;
IsDirty = false;
HasFile = true;
}
partial void OnKeywordsTextChanged(string value)
{
if (CurrentFile != null && value != (CurrentFile.Keywords ?? string.Empty))
IsDirty = true;
}
partial void OnNotesTextChanged(string value)
{
if (CurrentFile != null && value != (CurrentFile.Notes ?? string.Empty))
IsDirty = true;
}
partial void OnCategoryTextChanged(string value)
{
if (CurrentFile != null && value != (CurrentFile.Category ?? string.Empty))
IsDirty = true;
}
partial void OnDescriptionTextChanged(string value)
{
if (CurrentFile != null && value != (CurrentFile.Description ?? string.Empty))
IsDirty = true;
}
partial void OnRatingChanged(uint value)
{
if (CurrentFile != null && value != (CurrentFile.Rating ?? 0))
IsDirty = true;
}
[RelayCommand]
private void Save()
{
if (CurrentFile == null) return;
CurrentFile.Keywords = KeywordsText;
CurrentFile.Notes = NotesText;
CurrentFile.Category = CategoryText;
CurrentFile.Description = DescriptionText;
CurrentFile.Rating = Rating;
Database.UpdateEntry(CurrentFile);
IsDirty = false;
// 刷新 selected file 以反映变更
_channel.SelectedFile = CurrentFile;
}
[RelayCommand]
private void SetRating(uint rating)
{
Rating = rating;
}
}
+73
View File
@@ -0,0 +1,73 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using GUI.Services;
using OCES.Resonance.Core;
namespace GUI.ViewModels;
public partial class PlayerBarViewModel : ViewModelBase
{
private readonly SyncChannel _channel;
[ObservableProperty] private AudioFileMeta? _currentFile;
[ObservableProperty] private bool _hasFile;
[ObservableProperty] private string _currentFileName = "未选择文件";
[ObservableProperty] private bool _isPlaying;
[ObservableProperty] private double _currentPosition;
[ObservableProperty] private double _duration;
[ObservableProperty] private double _volume = 0.8;
public PlayerBarViewModel(SyncChannel channel)
{
_channel = channel;
_channel.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(SyncChannel.SelectedFile))
{
LoadFile(_channel.SelectedFile);
}
};
}
private void LoadFile(AudioFileMeta? file)
{
CurrentFile = file;
IsPlaying = false;
CurrentPosition = 0;
if (file == null)
{
CurrentFileName = "未选择文件";
Duration = 0;
HasFile = false;
return;
}
CurrentFileName = file.Filename;
Duration = file.Duration;
HasFile = true;
}
[RelayCommand]
private void PlayPause()
{
if (CurrentFile == null) return;
IsPlaying = !IsPlaying;
// TODO: 接入音频播放库后实现真正播放
}
[RelayCommand]
private void Stop()
{
IsPlaying = false;
CurrentPosition = 0;
// TODO: 接入音频播放库后实现真正停止
}
partial void OnCurrentPositionChanged(double value)
{
// TODO: 接入音频播放库后实现 seek
}
}
+109
View File
@@ -0,0 +1,109 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using OCES.Resonance.Core;
namespace GUI.ViewModels;
public partial class ScanProgressViewModel : ViewModelBase
{
[ObservableProperty] private bool _isScanning;
[ObservableProperty] private string _statusText = "准备扫描...";
[ObservableProperty] private int _totalFiles;
[ObservableProperty] private int _processedFiles;
[ObservableProperty] private int _addedFiles;
[ObservableProperty] private int _skippedFiles;
[ObservableProperty] private double _progress;
[ObservableProperty] private string _currentFile = string.Empty;
public event Action? ScanCompleted;
[RelayCommand]
private void StartScan(string directory)
{
if (string.IsNullOrWhiteSpace(directory)) return;
Task.Run(() => ScanDirectoryInternal(directory));
}
private void ScanDirectoryInternal(string directory)
{
try
{
IsScanning = true;
StatusText = "正在发现文件...";
Database.InitializeDatabase();
var scanner = new AudioFileScanner();
var reader = new AudioMetadataReader();
var validator = new AudioFileMetaValidator();
var files = scanner.ScanDirectory(directory, recursive: true).ToList();
TotalFiles = files.Count;
ProcessedFiles = 0;
AddedFiles = 0;
SkippedFiles = 0;
Progress = 0;
StatusText = $"发现 {TotalFiles} 个文件,正在解析元数据...";
foreach (var filePath in files)
{
CurrentFile = System.IO.Path.GetFileName(filePath);
ProcessedFiles++;
try
{
var fileInfo = new FileInfo(filePath);
var meta = reader.ReadMetadata(fileInfo);
if (meta == null)
{
SkippedFiles++;
UpdateProgress();
continue;
}
if (!validator.Validate(meta, out _))
{
SkippedFiles++;
UpdateProgress();
continue;
}
if (Database.EntryExists(meta.Md5, meta.Path))
{
SkippedFiles++;
UpdateProgress();
continue;
}
Database.AddEntry(meta);
AddedFiles++;
}
catch (Exception)
{
SkippedFiles++;
}
UpdateProgress();
}
StatusText = $"扫描完成:新增 {AddedFiles},跳过 {SkippedFiles}";
}
catch (Exception ex)
{
StatusText = $"扫描出错:{ex.Message}";
}
finally
{
IsScanning = false;
ScanCompleted?.Invoke();
}
}
private void UpdateProgress()
{
Progress = TotalFiles > 0 ? (double)ProcessedFiles / TotalFiles * 100 : 0;
}
}
+92
View File
@@ -0,0 +1,92 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using GUI.Services;
using OCES.Resonance.Core;
namespace GUI.ViewModels;
public partial class TagTreeViewModel : ViewModelBase
{
private readonly SyncChannel _channel;
public ObservableCollection<TagTreeNode> CategoryNodes { get; } = new();
public ObservableCollection<TagTreeNode> KeywordNodes { get; } = new();
[ObservableProperty] private TagTreeNode? _selectedNode;
public TagTreeViewModel(SyncChannel channel)
{
_channel = channel;
}
[RelayCommand]
private void LoadData()
{
try
{
CategoryNodes.Clear();
KeywordNodes.Clear();
var categories = Database.GetAllCategories();
foreach (var cat in categories)
{
CategoryNodes.Add(new TagTreeNode { Name = cat, Type = TagTreeNodeType.Category });
}
var keywords = Database.GetAllKeywords();
foreach (var kw in keywords)
{
KeywordNodes.Add(new TagTreeNode { Name = kw, Type = TagTreeNodeType.Keyword });
}
}
catch (Exception)
{
// 数据库尚未初始化或暂无数据
}
}
partial void OnSelectedNodeChanged(TagTreeNode? value)
{
if (value == null)
{
_channel.FilterCategory = null;
_channel.FilterKeyword = null;
return;
}
switch (value.Type)
{
case TagTreeNodeType.Category:
_channel.FilterCategory = value.Name;
_channel.FilterKeyword = null;
break;
case TagTreeNodeType.Keyword:
_channel.FilterCategory = null;
_channel.FilterKeyword = value.Name;
break;
}
}
[RelayCommand]
private void ClearFilter()
{
SelectedNode = null;
_channel.FilterCategory = null;
_channel.FilterKeyword = null;
}
}
public partial class TagTreeNode : ObservableObject
{
public string Name { get; set; } = string.Empty;
public TagTreeNodeType Type { get; set; }
public override string ToString() => Name;
}
public enum TagTreeNodeType
{
Category,
Keyword
}
+93
View File
@@ -0,0 +1,93 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:GUI.ViewModels"
xmlns:core="using:OCES.Resonance.Core"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:converters="using:GUI.Converters"
mc:Ignorable="d"
x:Class="GUI.Views.FileListView"
x:DataType="vm:FileListViewModel">
<Design.DataContext>
<vm:FileListViewModel/>
</Design.DataContext>
<UserControl.Resources>
<converters:DurationFormatConverter x:Key="DurationFmt"/>
<converters:SampleRateFormatConverter x:Key="SampleRateFmt"/>
<converters:ChannelsFormatConverter x:Key="ChannelsFmt"/>
<converters:RatingStarsConverter x:Key="RatingStarsFmt"/>
<converters:InverseBoolConverter x:Key="InverseBool"/>
</UserControl.Resources>
<Grid RowDefinitions="Auto,*" Margin="4">
<Border Grid.Row="0" Padding="6" BorderBrush="{DynamicResource SystemBaseLowColorBrush}"
BorderThickness="0,0,0,1">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto" Margin="0,0,0,4">
<TextBox Grid.Column="0" PlaceholderText="搜索文件..." Text="{Binding SearchText}"/>
<Button Grid.Column="1" Content="⟳" Command="{Binding RefreshFilesCommand}"
Width="28" Height="28" ToolTip.Tip="刷新" Margin="4,0,0,0"/>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="4">
<ComboBox ItemsSource="{Binding SortOptions}" SelectedItem="{Binding SelectedSortBy}"
Width="140" ToolTip.Tip="排序字段"/>
<Button Content="重置筛选" Command="{Binding ResetFiltersCommand}"
Height="28" Margin="8,0,0,0"/>
</StackPanel>
</StackPanel>
</Border>
<Grid Grid.Row="1">
<TextBlock Text="暂无文件,请先扫描目录添加音频文件。"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"
FontSize="14"
IsVisible="{Binding IsLoading, Converter={StaticResource InverseBool}}"/>
<ListBox ItemsSource="{Binding Files}"
SelectedItem="{Binding SelectedFile}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="core:AudioFileMeta">
<Grid ColumnDefinitions="40,*,80,70,60,50,60,80,80" Margin="2">
<TextBlock Grid.Column="0" Text="{Binding Id}" FontSize="11"
VerticalAlignment="Center" Margin="4,0"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Filename}" FontWeight="SemiBold"
FontSize="12" TextTrimming="CharacterEllipsis" MaxWidth="250"/>
<TextBlock Text="{Binding Path}" FontSize="10"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"
TextTrimming="CharacterEllipsis" MaxWidth="250"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding Duration, Converter={StaticResource DurationFmt}}"
VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<TextBlock Grid.Column="3" Text="{Binding SampleRate, Converter={StaticResource SampleRateFmt}}"
VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<TextBlock Grid.Column="4" Text="{Binding Channels, Converter={StaticResource ChannelsFmt}}"
VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<TextBlock Grid.Column="5" Text="{Binding BitDepth, StringFormat='{}{0}bit'}"
VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<TextBlock Grid.Column="6" Text="{Binding Type}"
VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<TextBlock Grid.Column="7" Text="{Binding Category}"
VerticalAlignment="Center" FontSize="11" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="8" Text="{Binding Rating, Converter={StaticResource RatingStarsFmt}}"
VerticalAlignment="Center" FontSize="10"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<ProgressBar IsVisible="{Binding IsLoading}"
IsIndeterminate="{Binding IsLoading}"
VerticalAlignment="Top"/>
</Grid>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace GUI.Views;
public partial class FileListView : UserControl
{
public FileListView()
{
InitializeComponent();
}
}
+47 -5
View File
@@ -1,20 +1,62 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:GUI.ViewModels" xmlns:vm="using:GUI.ViewModels"
xmlns:views="using:GUI.Views"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="800"
x:Class="GUI.Views.MainWindow" x:Class="GUI.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
x:Name="Root"
Icon="/Assets/avalonia-logo.ico" Icon="/Assets/avalonia-logo.ico"
Title="Resonance"> Title="Resonance"
Width="1200" Height="800"
MinWidth="900" MinHeight="600">
<Design.DataContext> <Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel/> <vm:MainWindowViewModel/>
</Design.DataContext> </Design.DataContext>
<TextBlock Text="{Binding Greeting}" HorizontalAlignment="Center" VerticalAlignment="Center"/> <Grid RowDefinitions="Auto,*,Auto">
<!-- 工具栏 -->
<Border Grid.Row="0" Padding="6,4" BorderBrush="{DynamicResource SystemBaseLowColorBrush}"
BorderThickness="0,0,0,1" Background="{DynamicResource SystemAltMediumColorBrush}">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Content="📁 扫描目录" x:Name="ScanButton" Height="28" Padding="8,0"/>
<Button Content="⚙ 偏好设置" Height="28" Padding="8,0" IsEnabled="False"/>
</StackPanel>
</Border>
<!-- 主内容区:三栏布局 -->
<Grid Grid.Row="1" ColumnDefinitions="220,4,*,4,300">
<!-- 左侧标签树 -->
<Border Grid.Column="0" BorderBrush="{DynamicResource SystemBaseLowColorBrush}"
BorderThickness="0,0,1,0">
<views:TagTreeView DataContext="{Binding TagTreeVM}"/>
</Border>
<GridSplitter Grid.Column="1" Width="4" Background="Transparent"/>
<!-- 中间文件列表 -->
<Border Grid.Column="2">
<views:FileListView DataContext="{Binding FileListVM}"/>
</Border>
<GridSplitter Grid.Column="3" Width="4" Background="Transparent"/>
<!-- 右侧元数据面板 -->
<Border Grid.Column="4" BorderBrush="{DynamicResource SystemBaseLowColorBrush}"
BorderThickness="1,0,0,0">
<views:MetadataPanelView DataContext="{Binding MetadataPanelVM}"/>
</Border>
</Grid>
<!-- 底部播放栏 -->
<Border Grid.Row="2">
<views:PlayerBarView DataContext="{Binding PlayerBarVM}"/>
</Border>
</Grid>
</Window> </Window>
+20
View File
@@ -1,4 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using GUI.ViewModels;
namespace GUI.Views; namespace GUI.Views;
@@ -7,5 +9,23 @@ public partial class MainWindow : Window
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
ScanButton.Click += OnScanButtonClick;
}
private async void OnScanButtonClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
var folders = await StorageProvider.OpenFolderPickerAsync(new Avalonia.Platform.Storage.FolderPickerOpenOptions
{
Title = "选择音频目录",
AllowMultiple = false
});
if (folders.Count > 0)
{
var path = folders[0].Path.LocalPath;
vm.RequestScan(path);
}
} }
} }
+96
View File
@@ -0,0 +1,96 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:GUI.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:converters="using:GUI.Converters"
mc:Ignorable="d"
x:Class="GUI.Views.MetadataPanelView"
x:DataType="vm:MetadataPanelViewModel">
<Design.DataContext>
<vm:MetadataPanelViewModel/>
</Design.DataContext>
<ScrollViewer>
<Grid RowDefinitions="Auto,*" Margin="8">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="4" Margin="0,0,0,8">
<TextBlock Text="元数据" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
<Button Content="保存" Command="{Binding SaveCommand}"
IsEnabled="{Binding IsDirty}"
Height="28" Width="60" HorizontalAlignment="Right"/>
</StackPanel>
<StackPanel Grid.Row="1" Spacing="8"
IsVisible="{Binding HasFile}">
<!-- 基础信息 -->
<Border Padding="6" BorderBrush="{DynamicResource SystemBaseLowColorBrush}" BorderThickness="1"
CornerRadius="4">
<StackPanel Spacing="4">
<TextBlock Text="基础信息" FontWeight="SemiBold" FontSize="12"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<TextBlock Text="{Binding CurrentFile.Filename}" FontSize="13" FontWeight="Bold"/>
<TextBlock Text="{Binding CurrentFile.Duration, StringFormat='时长: {0:F2}s'}"/>
<TextBlock Text="{Binding CurrentFile.SampleRate, StringFormat='采样率: {0} Hz'}"/>
<TextBlock Text="{Binding CurrentFile.BitDepth, StringFormat='位深: {0} bit'}"/>
<TextBlock Text="{Binding CurrentFile.Channels, StringFormat='声道: {0}'}"/>
<TextBlock Text="{Binding CurrentFile.Type, StringFormat='格式: {0}'}"/>
</StackPanel>
</Border>
<!-- 评分 -->
<Border Padding="6" BorderBrush="{DynamicResource SystemBaseLowColorBrush}" BorderThickness="1"
CornerRadius="4">
<StackPanel Spacing="4">
<TextBlock Text="评分" FontWeight="SemiBold" FontSize="12"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<StackPanel Orientation="Horizontal" Spacing="2">
<Button Content="☆" Command="{Binding SetRatingCommand}" CommandParameter="0"
Width="28" Height="28" FontSize="16"/>
<Button Content="★" Command="{Binding SetRatingCommand}" CommandParameter="1"
Width="28" Height="28" FontSize="16"/>
<Button Content="★" Command="{Binding SetRatingCommand}" CommandParameter="2"
Width="28" Height="28" FontSize="16"/>
<Button Content="★" Command="{Binding SetRatingCommand}" CommandParameter="3"
Width="28" Height="28" FontSize="16"/>
<Button Content="★" Command="{Binding SetRatingCommand}" CommandParameter="4"
Width="28" Height="28" FontSize="16"/>
<Button Content="★" Command="{Binding SetRatingCommand}" CommandParameter="5"
Width="28" Height="28" FontSize="16"/>
</StackPanel>
</StackPanel>
</Border>
<!-- 编辑区 -->
<Border Padding="6" BorderBrush="{DynamicResource SystemBaseLowColorBrush}" BorderThickness="1"
CornerRadius="4">
<StackPanel Spacing="6">
<TextBlock Text="标签与描述" FontWeight="SemiBold" FontSize="12"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<TextBlock Text="分类"/>
<TextBox Text="{Binding CategoryText}" PlaceholderText="输入分类..."/>
<TextBlock Text="关键词(逗号分隔)"/>
<TextBox Text="{Binding KeywordsText}" PlaceholderText="输入关键词..." MinHeight="40"
AcceptsReturn="True"/>
<TextBlock Text="描述"/>
<TextBox Text="{Binding DescriptionText}" PlaceholderText="输入描述..." MinHeight="40"
AcceptsReturn="True"/>
<TextBlock Text="备注"/>
<TextBox Text="{Binding NotesText}" PlaceholderText="输入备注..." MinHeight="40"
AcceptsReturn="True"/>
</StackPanel>
</Border>
</StackPanel>
<!-- 未选择文件 -->
<TextBlock Grid.Row="1" Text="选择文件查看元数据"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"
IsVisible="{Binding !HasFile}"/>
</Grid>
</ScrollViewer>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace GUI.Views;
public partial class MetadataPanelView : UserControl
{
public MetadataPanelView()
{
InitializeComponent();
}
}
+50
View File
@@ -0,0 +1,50 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:GUI.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:converters="using:GUI.Converters"
mc:Ignorable="d"
x:Class="GUI.Views.PlayerBarView"
x:DataType="vm:PlayerBarViewModel">
<Design.DataContext>
<vm:PlayerBarViewModel/>
</Design.DataContext>
<UserControl.Resources>
<converters:DurationToSliderMaxConverter x:Key="DurToSlider"/>
<converters:PositionFormatConverter x:Key="PosFmt"/>
<converters:DurationFormatConverter x:Key="DurFmt"/>
</UserControl.Resources>
<Border Padding="8,4" BorderBrush="{DynamicResource SystemBaseLowColorBrush}"
BorderThickness="0,1,0,0" Background="{DynamicResource SystemAltMediumColorBrush}">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" Height="48">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<Button Content="▶" Command="{Binding PlayPauseCommand}" Width="36" Height="36" FontSize="16"/>
<Button Content="⏹" Command="{Binding StopCommand}" Width="36" Height="36" FontSize="16"/>
</StackPanel>
<StackPanel Grid.Column="2" Margin="12,0" VerticalAlignment="Center" Spacing="2">
<Slider Minimum="0"
Maximum="{Binding Duration, Converter={StaticResource DurToSlider}}"
Value="{Binding CurrentPosition}"
IsEnabled="{Binding HasFile}"/>
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding CurrentPosition, Converter={StaticResource PosFmt}}"
FontSize="11" Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<TextBlock Grid.Column="2" Text="{Binding Duration, Converter={StaticResource DurFmt}}"
FontSize="11" Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
</Grid>
</StackPanel>
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center" Margin="0,0,12,0">
<TextBlock Text="{Binding CurrentFileName}"
MaxWidth="200" TextTrimming="CharacterEllipsis"
FontSize="12" VerticalAlignment="Center"/>
<Slider Value="{Binding Volume}" Minimum="0" Maximum="1" Width="80"
IsEnabled="{Binding HasFile}"/>
</StackPanel>
</Grid>
</Border>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace GUI.Views;
public partial class PlayerBarView : UserControl
{
public PlayerBarView()
{
InitializeComponent();
}
}
+34
View File
@@ -0,0 +1,34 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:GUI.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="GUI.Views.ScanProgressView"
x:DataType="vm:ScanProgressViewModel">
<Design.DataContext>
<vm:ScanProgressViewModel/>
</Design.DataContext>
<Border Padding="16" Background="{DynamicResource SystemBaseLowColorBrush}" CornerRadius="8"
MaxWidth="400">
<StackPanel Spacing="8">
<TextBlock Text="{Binding StatusText}" FontWeight="SemiBold" FontSize="13" TextWrapping="Wrap"/>
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="8"/>
<TextBlock FontSize="11" Foreground="{DynamicResource SystemBaseMediumColorBrush}">
<Run Text="已处理: "/>
<Run Text="{Binding ProcessedFiles}"/>
<Run Text=" / "/>
<Run Text="{Binding TotalFiles}"/>
</TextBlock>
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="{Binding AddedFiles, StringFormat='新增: {0}'}" FontSize="11"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<TextBlock Text="{Binding SkippedFiles, StringFormat='跳过: {0}'}" FontSize="11"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
</StackPanel>
<TextBlock Text="{Binding CurrentFile}" FontSize="11"
TextTrimming="CharacterEllipsis" MaxWidth="368"/>
</StackPanel>
</Border>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace GUI.Views;
public partial class ScanProgressView : UserControl
{
public ScanProgressView()
{
InitializeComponent();
}
}
+53
View File
@@ -0,0 +1,53 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:GUI.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="GUI.Views.TagTreeView"
x:DataType="vm:TagTreeViewModel">
<Design.DataContext>
<vm:TagTreeViewModel/>
</Design.DataContext>
<Grid RowDefinitions="Auto,*">
<StackPanel Orientation="Horizontal" Margin="8,8,8,4" Spacing="4">
<TextBlock Text="浏览" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
<Button Content="⟳" Command="{Binding LoadDataCommand}" ToolTip.Tip="刷新"
Width="28" Height="28" HorizontalAlignment="Right"/>
</StackPanel>
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="2" Margin="8,0,8,8">
<Button Content="全部文件" Command="{Binding ClearFilterCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Height="28" Margin="0,0,0,4"/>
<TextBlock Text="分类" FontWeight="SemiBold" Margin="0,8,0,2" FontSize="12"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<ListBox ItemsSource="{Binding CategoryNodes}"
SelectedItem="{Binding SelectedNode}"
Height="200">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TagTreeNode">
<TextBlock Text="{Binding Name}" Padding="4,2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Text="标签" FontWeight="SemiBold" Margin="0,8,0,2" FontSize="12"
Foreground="{DynamicResource SystemBaseMediumColorBrush}"/>
<ListBox ItemsSource="{Binding KeywordNodes}"
SelectedItem="{Binding SelectedNode}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TagTreeNode">
<TextBlock Text="{Binding Name}" Padding="4,2"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace GUI.Views;
public partial class TagTreeView : UserControl
{
public TagTreeView()
{
InitializeComponent();
}
}