Compare commits
10 Commits
0f73dfdb84
...
5ab48c4db6
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ab48c4db6 | |||
| fb1bd066f5 | |||
| e9eb69db5f | |||
| 9fa79febc5 | |||
| cb205d49db | |||
| 40b8524ffd | |||
| d72193a693 | |||
| adb24893e1 | |||
| 4ff375d132 | |||
| 87afe57bfa |
@@ -1,5 +1,10 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
|
## AI 行为准则
|
||||||
|
|
||||||
|
- **称呼规范**:始终称呼用户为「思谦」或「王思谦」。
|
||||||
|
- **信息缺失处理**:遇到任何不确定、拿不准或信息缺失的情况,不要自行推测。先收集整理好问题,停下来向思谦确认后再继续。
|
||||||
|
|
||||||
## Build & Test
|
## Build & Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -26,17 +31,12 @@ dotnet run --project src/GUI # Launch the Avalonia de
|
|||||||
|
|
||||||
## Known Issues / Gotchas
|
## Known Issues / Gotchas
|
||||||
|
|
||||||
|
- **升级 Avalonia 版本时,必须同步更新所有次级包引用。** 仅修改主包 `Avalonia` 的 Version 不会自动更新 `Avalonia.Desktop`、`Avalonia.Themes.Fluent`、`Avalonia.Fonts.Inter` 等次级包。必须手动逐个修改,然后执行 `dotnet clean && dotnet restore`。否则运行时的原生库(如 `libAvaloniaNative.dylib`)会混用新旧版本,导致 macOS 上的 `StorageProvider` 文件/文件夹选择对话框在回调时 SIGSEGV 崩溃。
|
||||||
|
- 相关 Avalonia issues: [#21102](https://github.com/AvaloniaUI/Avalonia/issues/21102), [#21150](https://github.com/AvaloniaUI/Avalonia/issues/21150), [#21313](https://github.com/AvaloniaUI/Avalonia/issues/21313),修复 PR: [#21104](https://github.com/AvaloniaUI/Avalonia/pull/21104)。
|
||||||
- **Tests create temp files/directories** under `Path.GetTempPath()` and clean them up via `IDisposable`. Don't rely on a real audio directory for tests.
|
- **Tests create temp files/directories** under `Path.GetTempPath()` and clean them up via `IDisposable`. Don't rely on a real audio directory for tests.
|
||||||
- **`AudioMetadataReader` tests** use real WAV files from `data/source/` (not in git). When files are absent, tests silently return early (`if (wavFiles.Length == 0) return;`) — they pass without actually running assertions, not `Skip.If`. Running `dotnet test` will report 12 passes regardless of whether the fixture files exist.
|
- **`AudioMetadataReader` tests** use real WAV files from `data/source/` (not in git). When files are absent, tests silently return early (`if (wavFiles.Length == 0) return;`) — they pass without actually running assertions, not `Skip.If`. Running `dotnet test` will report 12 passes regardless of whether the fixture files exist.
|
||||||
- **`.atl.txt` fixture files** are generated by `AtlFieldExtractor`. If the WAV files or the ATL library version change, re-run the extractor to regenerate them before running metadata tests.
|
- **`.atl.txt` fixture files** are generated by `AtlFieldExtractor`. If the WAV files or the ATL library version change, re-run the extractor to regenerate them before running metadata tests.
|
||||||
|
- 需要一个UI设计。现在的界面属实有点丑陋了。
|
||||||
## Data / Test Fixtures
|
|
||||||
|
|
||||||
- **`data/source/`** 包含有版权保护的 WAV 音频文件,**不会提交到 git**。按厂商分子目录:`Boom/`、`Wow Sound/`、`The Odessy/`、`Sound Idea/`。
|
|
||||||
- 每个 WAV 旁有对应的 `.atl.txt` 文件(由 `AtlFieldExtractor` 生成),记录了 ATL 库解析出的完整元数据,供测试断言参考。
|
|
||||||
- Boom 目录下的文件元数据最丰富(标准标签 + iXML 自定义字段 + BWF bext 字段 + 嵌入封面);Sound Idea 目录下的文件元数据最少(仅基础技术参数,无任何附加字段)。
|
|
||||||
- **`AudioMetadataReader` 测试**使用 `data/source/Boom/` 和 `Sound Idea/` 中的 WAV 文件。文件不在 git 仓库中,测试通过 `if (wavFiles.Length == 0) return;` 静默返回(测试仍然 pass,但未执行实际断言)。
|
|
||||||
- **路径解析**:测试运行时从 `bin/Debug/net10.0/` 启动,需向上回溯 6 级到达仓库根目录,再拼接 `data/source/`。
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
偏好设置: XDG_CONFIG_HOME
|
偏好设置: XDG_CONFIG_HOME
|
||||||
缓存数据: XDG_CACHE_HOME
|
缓存数据: XDG_CACHE_HOME
|
||||||
|
|
||||||
|
### TODO
|
||||||
|
|
||||||
|
- [ ] 扫描时如果报错,报错信息可能会填满整个窗口,导致Overlay无法关闭。
|
||||||
|
- [ ] 读取时如果报错,没有任何警告,会静默报错。需要有一个界面右下方的toast,或是发送系统通知告知用户遇到了错误。
|
||||||
|
- [x] 指针化Artwork字段。如果artwork的md5
|
||||||
|
- [ ] 本地化框架
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
.NET + Avaloina + SQLite
|
.NET + Avaloina + SQLite
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ class DumpCodingHistory
|
|||||||
{
|
{
|
||||||
Console.WriteLine($"处理: {wavFilePath}");
|
Console.WriteLine($"处理: {wavFilePath}");
|
||||||
|
|
||||||
var track = new Track(wavFilePath);
|
Track track = new(wavFilePath);
|
||||||
|
|
||||||
if (track.AdditionalFields == null ||
|
if (track.AdditionalFields == null ||
|
||||||
!track.AdditionalFields.TryGetValue("bext.codingHistory", out var codingHistory))
|
!track.AdditionalFields.TryGetValue("bext.codingHistory", out string? codingHistory))
|
||||||
{
|
{
|
||||||
Console.WriteLine(" -> 无 bext.codingHistory 字段");
|
Console.WriteLine(" -> 无 bext.codingHistory 字段");
|
||||||
return;
|
return;
|
||||||
@@ -53,8 +53,8 @@ class DumpCodingHistory
|
|||||||
Console.WriteLine($" -> 字符串长度: {codingHistory.Length}");
|
Console.WriteLine($" -> 字符串长度: {codingHistory.Length}");
|
||||||
|
|
||||||
// 用 Latin-1 编码转回字节(Latin-1 把 Unicode 0x00-0xFF 直接映射为 byte 0x00-0xFF)
|
// 用 Latin-1 编码转回字节(Latin-1 把 Unicode 0x00-0xFF 直接映射为 byte 0x00-0xFF)
|
||||||
var bytes = System.Text.Encoding.Latin1.GetBytes(codingHistory);
|
byte[] bytes = System.Text.Encoding.Latin1.GetBytes(codingHistory);
|
||||||
var outputPath = Path.Combine(
|
string outputPath = Path.Combine(
|
||||||
Path.GetDirectoryName(wavFilePath)!,
|
Path.GetDirectoryName(wavFilePath)!,
|
||||||
Path.GetFileNameWithoutExtension(wavFilePath) + ".codingHistory.bin"
|
Path.GetFileNameWithoutExtension(wavFilePath) + ".codingHistory.bin"
|
||||||
);
|
);
|
||||||
@@ -64,7 +64,7 @@ class DumpCodingHistory
|
|||||||
Console.WriteLine($" -> 字节数: {bytes.Length}");
|
Console.WriteLine($" -> 字节数: {bytes.Length}");
|
||||||
|
|
||||||
// 打印前 64 个字节的 hex
|
// 打印前 64 个字节的 hex
|
||||||
var hexPreview = Math.Min(64, bytes.Length);
|
int hexPreview = Math.Min(64, bytes.Length);
|
||||||
Console.Write(" -> Hex 前 64 字节: ");
|
Console.Write(" -> Hex 前 64 字节: ");
|
||||||
for (int i = 0; i < hexPreview; i++)
|
for (int i = 0; i < hexPreview; i++)
|
||||||
{
|
{
|
||||||
@@ -77,7 +77,7 @@ class DumpCodingHistory
|
|||||||
Console.Write(" -> ASCII 前 64 字节: ");
|
Console.Write(" -> ASCII 前 64 字节: ");
|
||||||
for (int i = 0; i < hexPreview; i++)
|
for (int i = 0; i < hexPreview; i++)
|
||||||
{
|
{
|
||||||
var c = (char)bytes[i];
|
char c = (char)bytes[i];
|
||||||
Console.Write(c >= 32 && c < 127 ? c : '.');
|
Console.Write(c >= 32 && c < 127 ? c : '.');
|
||||||
if ((i + 1) % 16 == 0) Console.Write(" ");
|
if ((i + 1) % 16 == 0) Console.Write(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 查找所有 WAV 文件
|
// 查找所有 WAV 文件
|
||||||
var wavFiles = Directory.GetFiles(sourceDir, "*.wav", SearchOption.AllDirectories);
|
string[] wavFiles = Directory.GetFiles(sourceDir, "*.wav", SearchOption.AllDirectories);
|
||||||
|
|
||||||
Console.WriteLine($"找到 {wavFiles.Length} 个 WAV 文件");
|
Console.WriteLine($"找到 {wavFiles.Length} 个 WAV 文件");
|
||||||
|
|
||||||
foreach (var wavFile in wavFiles)
|
foreach (string wavFile in wavFiles)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -44,10 +44,10 @@ class Program
|
|||||||
{
|
{
|
||||||
Console.WriteLine($"处理: {wavFilePath}");
|
Console.WriteLine($"处理: {wavFilePath}");
|
||||||
|
|
||||||
var track = new Track(wavFilePath);
|
Track track = new(wavFilePath);
|
||||||
var outputPath = Path.ChangeExtension(wavFilePath, ".atl.txt");
|
string outputPath = Path.ChangeExtension(wavFilePath, ".atl.txt");
|
||||||
|
|
||||||
using var writer = new StreamWriter(outputPath);
|
using StreamWriter writer = new(outputPath);
|
||||||
writer.WriteLine("=== ATL Track 基础属性 ===");
|
writer.WriteLine("=== ATL Track 基础属性 ===");
|
||||||
writer.WriteLine();
|
writer.WriteLine();
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ class Program
|
|||||||
// 打印所有附加字段
|
// 打印所有附加字段
|
||||||
if (track.AdditionalFields != null && track.AdditionalFields.Count > 0)
|
if (track.AdditionalFields != null && track.AdditionalFields.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var field in track.AdditionalFields.OrderBy(f => f.Key))
|
foreach (KeyValuePair<string, string> field in track.AdditionalFields.OrderBy(f => f.Key))
|
||||||
{
|
{
|
||||||
writer.WriteLine($"[{field.Key}] = {field.Value}");
|
writer.WriteLine($"[{field.Key}] = {field.Value}");
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ class Program
|
|||||||
// 打印图片信息
|
// 打印图片信息
|
||||||
if (track.EmbeddedPictures != null && track.EmbeddedPictures.Count > 0)
|
if (track.EmbeddedPictures != null && track.EmbeddedPictures.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var pic in track.EmbeddedPictures)
|
foreach (PictureInfo? pic in track.EmbeddedPictures)
|
||||||
{
|
{
|
||||||
writer.WriteLine($"PictureType: {pic.PicType}");
|
writer.WriteLine($"PictureType: {pic.PicType}");
|
||||||
writer.WriteLine($"Description: {pic.Description}");
|
writer.WriteLine($"Description: {pic.Description}");
|
||||||
@@ -129,7 +129,7 @@ class Program
|
|||||||
// 打印章节信息
|
// 打印章节信息
|
||||||
if (track.Chapters != null && track.Chapters.Count > 0)
|
if (track.Chapters != null && track.Chapters.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var chapter in track.Chapters)
|
foreach (ChapterInfo? chapter in track.Chapters)
|
||||||
{
|
{
|
||||||
writer.WriteLine($"Title: {chapter.Title}");
|
writer.WriteLine($"Title: {chapter.Title}");
|
||||||
writer.WriteLine($"StartTime: {chapter.StartTime}");
|
writer.WriteLine($"StartTime: {chapter.StartTime}");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Blake3" Version="2.2.1" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
|
||||||
<PackageReference Include="FluentAssertions" Version="8.9.0" />
|
<PackageReference Include="FluentAssertions" Version="8.9.0" />
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public class DatabaseTests
|
|||||||
Assert.Contains("bpm", columns);
|
Assert.Contains("bpm", columns);
|
||||||
Assert.Contains("description", columns);
|
Assert.Contains("description", columns);
|
||||||
|
|
||||||
|
Assert.Contains("artwork_fingerprint", columns);
|
||||||
|
Assert.DoesNotContain("artwork", columns); // 旧 BLOB 列不应存在
|
||||||
|
|
||||||
// 3. 索引存在
|
// 3. 索引存在
|
||||||
HashSet<string> indexes = conn.Query<string>(
|
HashSet<string> indexes = conn.Query<string>(
|
||||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='audio_files';")
|
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='audio_files';")
|
||||||
@@ -49,6 +52,17 @@ public class DatabaseTests
|
|||||||
Assert.Contains("idx_path", indexes);
|
Assert.Contains("idx_path", indexes);
|
||||||
Assert.Contains("idx_unique_id", indexes);
|
Assert.Contains("idx_unique_id", indexes);
|
||||||
Assert.Contains("idx_description", indexes);
|
Assert.Contains("idx_description", indexes);
|
||||||
|
Assert.Contains("idx_artwork_fingerprint", indexes);
|
||||||
|
|
||||||
|
// 4. artworks 表存在
|
||||||
|
int artworksTableCount = conn.ExecuteScalar<int>(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='artworks';");
|
||||||
|
Assert.Equal(1, artworksTableCount);
|
||||||
|
|
||||||
|
HashSet<string> artworksColumns = conn.Query<string>(
|
||||||
|
"SELECT name FROM pragma_table_info('artworks') ORDER BY cid;").ToHashSet();
|
||||||
|
Assert.Contains("fingerprint", artworksColumns);
|
||||||
|
Assert.Contains("data", artworksColumns);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -110,7 +124,7 @@ public class DatabaseTests
|
|||||||
Description = "Test explosion sound",
|
Description = "Test explosion sound",
|
||||||
Category = "SFX",
|
Category = "SFX",
|
||||||
Keywords = "test,boom,explosion",
|
Keywords = "test,boom,explosion",
|
||||||
FxName = "Test Explosion Boom"
|
FxName = "Test Explosion Boom",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,4 +317,107 @@ public class DatabaseTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Artwork 测试
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddEntry_WithArtwork_ShouldStoreFingerprintAndRetrieveData()
|
||||||
|
{
|
||||||
|
Database.InitializeDatabase();
|
||||||
|
AudioFileMeta meta = CreateSampleMeta();
|
||||||
|
byte[] artworkData = [0x89, 0x50, 0x4E, 0x47]; // PNG 幻数
|
||||||
|
meta.Artwork = artworkData;
|
||||||
|
|
||||||
|
Assert.True(Database.AddEntry(meta));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AudioFileMeta? retrieved = Database.GetEntryById(meta.Id);
|
||||||
|
Assert.NotNull(retrieved);
|
||||||
|
Assert.NotNull(retrieved!.ArtworkFingerprint);
|
||||||
|
Assert.Equal(64, retrieved.ArtworkFingerprint!.Length);
|
||||||
|
|
||||||
|
byte[]? actualData = Database.GetArtworkData(retrieved.ArtworkFingerprint);
|
||||||
|
Assert.NotNull(actualData);
|
||||||
|
Assert.Equal(artworkData, actualData);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteTestEntries(meta.UniqueId);
|
||||||
|
Database.DeleteOrphanedArtworks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddEntries_WithDuplicateArtwork_ShouldDeduplicate()
|
||||||
|
{
|
||||||
|
Database.InitializeDatabase();
|
||||||
|
byte[] artworkData = [0xFF, 0xD8, 0xFF, 0xE0]; // JPEG 幻数
|
||||||
|
|
||||||
|
AudioFileMeta meta1 = CreateSampleMeta();
|
||||||
|
meta1.Artwork = artworkData;
|
||||||
|
AudioFileMeta meta2 = CreateSampleMeta();
|
||||||
|
meta2.Artwork = artworkData;
|
||||||
|
|
||||||
|
Database.AddEntries([meta1, meta2]);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.NotNull(meta1.ArtworkFingerprint);
|
||||||
|
Assert.Equal(meta1.ArtworkFingerprint, meta2.ArtworkFingerprint);
|
||||||
|
|
||||||
|
// artworks 表中只应有一条记录
|
||||||
|
using SQLiteConnection conn = new($"Data Source={GetDefaultDbPath()};Version=3;");
|
||||||
|
conn.Open();
|
||||||
|
int artworkCount = conn.ExecuteScalar<int>(
|
||||||
|
"SELECT COUNT(*) FROM artworks;");
|
||||||
|
Assert.Equal(1, artworkCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteTestEntries(meta1.UniqueId, meta2.UniqueId);
|
||||||
|
Database.DeleteOrphanedArtworks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteOrphanedArtworks_ShouldRemoveUnreferencedRecords()
|
||||||
|
{
|
||||||
|
Database.InitializeDatabase();
|
||||||
|
byte[] artworkData = [0x47, 0x49, 0x46, 0x38]; // GIF 幻数
|
||||||
|
|
||||||
|
AudioFileMeta meta = CreateSampleMeta();
|
||||||
|
meta.Artwork = artworkData;
|
||||||
|
Database.AddEntry(meta);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string fingerprint = meta.ArtworkFingerprint!;
|
||||||
|
Assert.NotNull(Database.GetArtworkData(fingerprint));
|
||||||
|
|
||||||
|
// 删除音频文件后,artwork 成为孤儿
|
||||||
|
DeleteTestEntries(meta.UniqueId);
|
||||||
|
|
||||||
|
int deleted = Database.DeleteOrphanedArtworks();
|
||||||
|
Assert.True(deleted >= 1); // 至少删除本次测试产生的孤儿
|
||||||
|
|
||||||
|
// 确认本次测试的 artwork 已被清理
|
||||||
|
Assert.Null(Database.GetArtworkData(fingerprint));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteTestEntries(meta.UniqueId);
|
||||||
|
Database.DeleteOrphanedArtworks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetArtworkData_NonExistent_ShouldReturnNull()
|
||||||
|
{
|
||||||
|
Database.InitializeDatabase();
|
||||||
|
byte[]? result = Database.GetArtworkData("nonexistentfingerprint000000000000000000000000000000000000000000000000");
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ public class IntegratedTest
|
|||||||
Assert.Equal(meta.Type, retrieved.Type);
|
Assert.Equal(meta.Type, retrieved.Type);
|
||||||
|
|
||||||
if (meta.Artwork != null)
|
if (meta.Artwork != null)
|
||||||
Assert.NotNull(retrieved.Artwork);
|
{
|
||||||
|
Assert.NotNull(retrieved.ArtworkFingerprint);
|
||||||
|
byte[]? artworkData = Database.GetArtworkData(retrieved.ArtworkFingerprint!);
|
||||||
|
Assert.NotNull(artworkData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ public class AudioFileMeta
|
|||||||
|
|
||||||
public string? MicPerspective { get; set; }
|
public string? MicPerspective { get; set; }
|
||||||
|
|
||||||
|
/// <summary>封面图片指纹(Blake3 十六进制),映射 artworks 表;实际图片数据通过 Database.GetArtworkData() 获取</summary>
|
||||||
|
public string? ArtworkFingerprint { get; set; }
|
||||||
|
|
||||||
|
/// <summary>封面图片原始数据,仅用于导入管线暂存,不映射数据库列</summary>
|
||||||
public byte[]? Artwork { get; set; }
|
public byte[]? Artwork { get; set; }
|
||||||
|
|
||||||
public byte[]? Waveform { get; set; }
|
public byte[]? Waveform { get; set; }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Blake3" Version="2.2.1" />
|
||||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageReference Include="System.Data.SQLite" Version="2.0.3" />
|
<PackageReference Include="System.Data.SQLite" Version="2.0.3" />
|
||||||
|
|||||||
+168
-16
@@ -1,5 +1,6 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Data.SQLite;
|
using System.Data.SQLite;
|
||||||
|
using Blake3;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
|
|
||||||
namespace OCES.Resonance.Core;
|
namespace OCES.Resonance.Core;
|
||||||
@@ -106,16 +107,22 @@ public static class Database
|
|||||||
channel_layout TEXT,
|
channel_layout TEXT,
|
||||||
bwf_umid BLOB,
|
bwf_umid BLOB,
|
||||||
disk_number INTEGER,
|
disk_number INTEGER,
|
||||||
track_number INTEGER,
|
track_number INTEGER,
|
||||||
artwork BLOB,
|
artwork_fingerprint TEXT,
|
||||||
waveform BLOB
|
waveform BLOB
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX if not exists idx_file_name on audio_files(filename);
|
CREATE INDEX if not exists idx_file_name on audio_files(filename);
|
||||||
CREATE INDEX if not exists idx_md5 on audio_files(md5);
|
CREATE INDEX if not exists idx_md5 on audio_files(md5);
|
||||||
CREATE INDEX if not exists idx_path on audio_files(path);
|
CREATE INDEX if not exists idx_path on audio_files(path);
|
||||||
CREATE INDEX if not exists idx_unique_id on audio_files(unique_id);
|
CREATE INDEX if not exists idx_unique_id on audio_files(unique_id);
|
||||||
CREATE INDEX if not exists idx_description on audio_files(description);
|
CREATE INDEX if not exists idx_description on audio_files(description);
|
||||||
|
CREATE INDEX if not exists idx_artwork_fingerprint on audio_files(artwork_fingerprint);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS artworks (
|
||||||
|
fingerprint TEXT PRIMARY KEY,
|
||||||
|
data BLOB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -147,7 +154,7 @@ public static class Database
|
|||||||
release_date, track_year, is_edited, is_split, location, [group],
|
release_date, track_year, is_edited, is_split, location, [group],
|
||||||
markers, comments, notes, copyright, coding_history, microphone,
|
markers, comments, notes, copyright, coding_history, microphone,
|
||||||
mic_perspective,
|
mic_perspective,
|
||||||
fx_name, channel_layout, bwf_umid, disk_number, track_number, artwork, waveform,
|
fx_name, channel_layout, bwf_umid, disk_number, track_number, artwork_fingerprint, waveform,
|
||||||
user1, user2, user3, user4, user5, user6, user7, user8
|
user1, user2, user3, user4, user5, user6, user7, user8
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@UniqueId, @ShortId, @Md5, @Path, @Filename, @Folder, @Directory,
|
@UniqueId, @ShortId, @Md5, @Path, @Filename, @Folder, @Directory,
|
||||||
@@ -161,11 +168,17 @@ public static class Database
|
|||||||
@ReleaseDate, @TrackYear, @IsEdited, @IsSplit, @Location, @Group,
|
@ReleaseDate, @TrackYear, @IsEdited, @IsSplit, @Location, @Group,
|
||||||
@Markers, @Comments, @Notes, @Copyright, @BwfCodingHistory, @Microphone,
|
@Markers, @Comments, @Notes, @Copyright, @BwfCodingHistory, @Microphone,
|
||||||
@MicPerspective,
|
@MicPerspective,
|
||||||
@FxName, @ChannelLayout, @BwfUmid, @DiskNumber, @TrackNumber, @Artwork, @Waveform,
|
@FxName, @ChannelLayout, @BwfUmid, @DiskNumber, @TrackNumber, @ArtworkFingerprint, @Waveform,
|
||||||
@User1, @User2, @User3, @User4, @User5, @User6, @User7, @User8
|
@User1, @User2, @User3, @User4, @User5, @User6, @User7, @User8
|
||||||
);
|
);
|
||||||
";
|
";
|
||||||
|
|
||||||
|
if (meta.Artwork is { Length: > 0 })
|
||||||
|
{
|
||||||
|
meta.ArtworkFingerprint = ComputeArtworkFingerprint(meta.Artwork);
|
||||||
|
EnsureArtwork(connection, null, meta.ArtworkFingerprint, meta.Artwork);
|
||||||
|
}
|
||||||
|
|
||||||
int newId = connection.ExecuteScalar<int>(sql + "; SELECT last_insert_rowid();", meta);
|
int newId = connection.ExecuteScalar<int>(sql + "; SELECT last_insert_rowid();", meta);
|
||||||
meta.Id = newId;
|
meta.Id = newId;
|
||||||
return true;
|
return true;
|
||||||
@@ -203,7 +216,7 @@ public static class Database
|
|||||||
release_date, track_year, is_edited, is_split, location, [group],
|
release_date, track_year, is_edited, is_split, location, [group],
|
||||||
markers, comments, notes, copyright, coding_history, microphone,
|
markers, comments, notes, copyright, coding_history, microphone,
|
||||||
mic_perspective,
|
mic_perspective,
|
||||||
fx_name, channel_layout, bwf_umid, disk_number, track_number, artwork, waveform,
|
fx_name, channel_layout, bwf_umid, disk_number, track_number, artwork_fingerprint, waveform,
|
||||||
user1, user2, user3, user4, user5, user6, user7, user8
|
user1, user2, user3, user4, user5, user6, user7, user8
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@UniqueId, @ShortId, @Md5, @Path, @Filename, @Folder, @Directory,
|
@UniqueId, @ShortId, @Md5, @Path, @Filename, @Folder, @Directory,
|
||||||
@@ -217,13 +230,18 @@ public static class Database
|
|||||||
@ReleaseDate, @TrackYear, @IsEdited, @IsSplit, @Location, @Group,
|
@ReleaseDate, @TrackYear, @IsEdited, @IsSplit, @Location, @Group,
|
||||||
@Markers, @Comments, @Notes, @Copyright, @BwfCodingHistory, @Microphone,
|
@Markers, @Comments, @Notes, @Copyright, @BwfCodingHistory, @Microphone,
|
||||||
@MicPerspective,
|
@MicPerspective,
|
||||||
@FxName, @ChannelLayout, @BwfUmid, @DiskNumber, @TrackNumber, @Artwork, @Waveform,
|
@FxName, @ChannelLayout, @BwfUmid, @DiskNumber, @TrackNumber, @ArtworkFingerprint, @Waveform,
|
||||||
@User1, @User2, @User3, @User4, @User5, @User6, @User7, @User8
|
@User1, @User2, @User3, @User4, @User5, @User6, @User7, @User8
|
||||||
);
|
);
|
||||||
";
|
";
|
||||||
|
|
||||||
foreach (AudioFileMeta entry in entries)
|
foreach (AudioFileMeta entry in entries)
|
||||||
{
|
{
|
||||||
|
if (entry.Artwork is { Length: > 0 })
|
||||||
|
{
|
||||||
|
entry.ArtworkFingerprint = ComputeArtworkFingerprint(entry.Artwork);
|
||||||
|
EnsureArtwork(connection, transaction, entry.ArtworkFingerprint, entry.Artwork);
|
||||||
|
}
|
||||||
connection.Execute(sql, entry, transaction);
|
connection.Execute(sql, entry, transaction);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
@@ -275,10 +293,10 @@ public static class Database
|
|||||||
markers, comments, notes, copyright, coding_history AS bwf_coding_history, microphone,
|
markers, comments, notes, copyright, coding_history AS bwf_coding_history, microphone,
|
||||||
mic_perspective,
|
mic_perspective,
|
||||||
fx_name, channel_layout, disk_number, track_number,
|
fx_name, channel_layout, disk_number, track_number,
|
||||||
artwork, waveform, bwf_umid,
|
artwork_fingerprint, waveform, bwf_umid,
|
||||||
user1, user2, user3, user4, user5, user6, user7, user8";
|
user1, user2, user3, user4, user5, user6, user7, user8";
|
||||||
|
|
||||||
// 列表查询公共列(排除 BLOB:artwork / waveform / bwf_umid),减少数据传输
|
// 列表查询公共列(排除 BLOB:waveform / bwf_umid),减少数据传输
|
||||||
private const string ListSelectColumns = @"
|
private const string ListSelectColumns = @"
|
||||||
id, unique_id, short_id, md5, path, filename, folder, directory,
|
id, unique_id, short_id, md5, path, filename, folder, directory,
|
||||||
duration, bit_depth, channels, sample_rate, type,
|
duration, bit_depth, channels, sample_rate, type,
|
||||||
@@ -292,13 +310,14 @@ public static class Database
|
|||||||
markers, comments, notes, copyright, coding_history AS bwf_coding_history, microphone,
|
markers, comments, notes, copyright, coding_history AS bwf_coding_history, microphone,
|
||||||
mic_perspective,
|
mic_perspective,
|
||||||
fx_name, channel_layout, disk_number, track_number,
|
fx_name, channel_layout, disk_number, track_number,
|
||||||
|
artwork_fingerprint,
|
||||||
user1, user2, user3, user4, user5, user6, user7, user8";
|
user1, user2, user3, user4, user5, user6, user7, user8";
|
||||||
|
|
||||||
// 排序白名单,防止 SQL 注入
|
// 排序白名单,防止 SQL 注入
|
||||||
private static readonly HashSet<string> AllowedSortColumns = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> AllowedSortColumns = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"filename", "duration", "sample_rate", "channels",
|
"filename", "duration", "sample_rate", "channels",
|
||||||
"date_added", "rating", "type", "category", "id"
|
"date_added", "rating", "type", "category", "id",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -464,5 +483,138 @@ 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 != '';";
|
||||||
|
IEnumerable<string> rows = connection.Query<string>(sql);
|
||||||
|
HashSet<string> allKeywords = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (string row in rows)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(row)) continue;
|
||||||
|
foreach (string 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
|
||||||
|
|
||||||
|
#region Artwork
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算 artwork 数据的 Blake3 指纹
|
||||||
|
/// </summary>
|
||||||
|
private static string ComputeArtworkFingerprint(byte[] data)
|
||||||
|
{
|
||||||
|
return Hasher.Hash(data).ToString().ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 确保 artwork 数据已存入 artworks 表(指纹不存在时插入)
|
||||||
|
/// </summary>
|
||||||
|
private static void EnsureArtwork(IDbConnection connection, IDbTransaction? transaction, string fingerprint, byte[] data)
|
||||||
|
{
|
||||||
|
const string sql = "INSERT OR IGNORE INTO artworks (fingerprint, data) VALUES (@Fingerprint, @Data);";
|
||||||
|
connection.Execute(sql, new { Fingerprint = fingerprint, Data = data }, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据指纹获取 artwork 原始数据
|
||||||
|
/// </summary>
|
||||||
|
public static byte[]? GetArtworkData(string fingerprint)
|
||||||
|
{
|
||||||
|
using IDbConnection connection = GetConnection();
|
||||||
|
connection.Open();
|
||||||
|
const string sql = "SELECT data FROM artworks WHERE fingerprint = @Fingerprint;";
|
||||||
|
return connection.ExecuteScalar<byte[]>(sql, new { Fingerprint = fingerprint });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清理 artworks 表中无 audio_files 引用的孤儿记录
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>删除的记录数</returns>
|
||||||
|
public static int DeleteOrphanedArtworks()
|
||||||
|
{
|
||||||
|
using IDbConnection connection = GetConnection();
|
||||||
|
connection.Open();
|
||||||
|
const string sql = """
|
||||||
|
DELETE FROM artworks
|
||||||
|
WHERE fingerprint NOT IN (
|
||||||
|
SELECT DISTINCT artwork_fingerprint
|
||||||
|
FROM audio_files
|
||||||
|
WHERE artwork_fingerprint IS NOT NULL
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
return connection.Execute(sql);
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public static class PreferencesManager
|
|||||||
// 文件不存在或反序列化失败 → 创建默认实例
|
// 文件不存在或反序列化失败 → 创建默认实例
|
||||||
CurrentPreferences = new Preferences
|
CurrentPreferences = new Preferences
|
||||||
{
|
{
|
||||||
DatabasePath = GetDefaultDatabasePath()
|
DatabasePath = GetDefaultDatabasePath(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 立即落盘,确保下次启动能直接读取
|
// 立即落盘,确保下次启动能直接读取
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<Application xmlns="https://github.com/avaloniaui"
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="GUI.App"
|
x:Class="GUI.App"
|
||||||
|
Name="Resonance"
|
||||||
xmlns:local="using:GUI"
|
xmlns:local="using:GUI"
|
||||||
RequestedThemeVariant="Default">
|
RequestedThemeVariant="Default">
|
||||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||||
@@ -8,6 +9,14 @@
|
|||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
<local:ViewLocator/>
|
<local:ViewLocator/>
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|
||||||
|
|
||||||
|
<NativeMenu.Menu>
|
||||||
|
<NativeMenu>
|
||||||
|
<NativeMenuItem Header="关于…" />
|
||||||
|
<NativeMenuItem Header="偏好设置…"/>
|
||||||
|
</NativeMenu>
|
||||||
|
</NativeMenu.Menu>
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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 "--:--";
|
||||||
|
TimeSpan 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";
|
||||||
|
TimeSpan 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 class HalfDoubleConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is double d && !double.IsNaN(d) && !double.IsInfinity(d))
|
||||||
|
return d / 2.0;
|
||||||
|
return 400.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MiddleTruncateConverter : IValueConverter
|
||||||
|
{
|
||||||
|
private const int KeepTail = 10;
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is not string text || string.IsNullOrEmpty(text)) return value;
|
||||||
|
|
||||||
|
int maxLength = 60;
|
||||||
|
if (parameter != null && int.TryParse(parameter.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
|
||||||
|
maxLength = parsed;
|
||||||
|
|
||||||
|
if (text.Length <= maxLength) return text;
|
||||||
|
|
||||||
|
int head = maxLength - KeepTail - 3;
|
||||||
|
if (head <= 0) return text[..maxLength];
|
||||||
|
|
||||||
|
return string.Concat(text.AsSpan(0, head), "...", text.AsSpan(text.Length - KeepTail, KeepTail));
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
public static readonly IValueConverter HalfDouble = new HalfDoubleConverter();
|
||||||
|
public static readonly IValueConverter MiddleTruncate = new MiddleTruncateConverter();
|
||||||
|
}
|
||||||
+6
-4
@@ -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>
|
||||||
@@ -13,16 +14,17 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="12.0.0"/>
|
<PackageReference Include="Avalonia" Version="12.0.3" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.0"/>
|
<PackageReference Include="Avalonia.Desktop" Version="12.0.3"/>
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0"/>
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.3"/>
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0"/>
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.3"/>
|
||||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
||||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1"/>
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1"/>
|
||||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.93.0" />
|
<PackageReference Include="Tmds.DBus.Protocol" Version="0.93.0" />
|
||||||
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 SyncChannel? channel))
|
||||||
|
{
|
||||||
|
channel = new SyncChannel(id);
|
||||||
|
_channels[id] = channel;
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SyncChannel Default => GetOrCreate("default");
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ public class ViewLocator : IDataTemplate
|
|||||||
if (param is null)
|
if (param is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
string name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||||
var type = Type.GetType(name);
|
Type? type = Type.GetType(name);
|
||||||
|
|
||||||
if (type != null)
|
if (type != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
IEnumerable<AudioFileMeta> 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 (AudioFileMeta 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,71 @@
|
|||||||
namespace GUI.ViewModels;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void CleanOrphanedArtwork()
|
||||||
|
{
|
||||||
|
int count = OCES.Resonance.Core.Database.DeleteOrphanedArtworks();
|
||||||
|
// TODO: 添加用户提示,例如对话框显示 "已清理 {count} 条未使用的资源"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _hasError;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Dismiss()
|
||||||
|
{
|
||||||
|
IsScanning = false;
|
||||||
|
HasError = false;
|
||||||
|
StatusText = "准备扫描...";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ScanDirectoryInternal(string directory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HasError = false;
|
||||||
|
IsScanning = true;
|
||||||
|
StatusText = "正在发现文件...";
|
||||||
|
|
||||||
|
Database.InitializeDatabase();
|
||||||
|
|
||||||
|
AudioFileScanner scanner = new();
|
||||||
|
AudioMetadataReader reader = new();
|
||||||
|
AudioFileMetaValidator validator = new();
|
||||||
|
|
||||||
|
List<string> files = scanner.ScanDirectory(directory, recursive: true).ToList();
|
||||||
|
TotalFiles = files.Count;
|
||||||
|
ProcessedFiles = 0;
|
||||||
|
AddedFiles = 0;
|
||||||
|
SkippedFiles = 0;
|
||||||
|
Progress = 0;
|
||||||
|
|
||||||
|
StatusText = $"发现 {TotalFiles} 个文件,正在解析元数据...";
|
||||||
|
|
||||||
|
foreach (string filePath in files)
|
||||||
|
{
|
||||||
|
CurrentFile = Path.GetFileName(filePath);
|
||||||
|
ProcessedFiles++;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FileInfo fileInfo = new(filePath);
|
||||||
|
AudioFileMeta? 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)
|
||||||
|
{
|
||||||
|
HasError = true;
|
||||||
|
StatusText = $"扫描出错:{ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!HasError)
|
||||||
|
IsScanning = false;
|
||||||
|
ScanCompleted?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateProgress()
|
||||||
|
{
|
||||||
|
Progress = TotalFiles > 0 ? (double)ProcessedFiles / TotalFiles * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
IEnumerable<string> categories = Database.GetAllCategories();
|
||||||
|
foreach (string cat in categories)
|
||||||
|
{
|
||||||
|
CategoryNodes.Add(new TagTreeNode { Name = cat, Type = TagTreeNodeType.Category });
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<string> keywords = Database.GetAllKeywords();
|
||||||
|
foreach (string 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,
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace GUI.Views;
|
||||||
|
|
||||||
|
public partial class FileListView : UserControl
|
||||||
|
{
|
||||||
|
public FileListView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,82 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<NativeMenu.Menu>
|
||||||
|
<NativeMenu>
|
||||||
|
<NativeMenuItem Header="Database" IsVisible="True">
|
||||||
|
<NativeMenu>
|
||||||
|
<NativeMenuItem Header="Delete Unused Artworks" Command="{Binding CleanOrphanedArtworkCommand}"></NativeMenuItem>
|
||||||
|
<NativeMenuItem Header="Open database file in Finder..."/>
|
||||||
|
</NativeMenu>
|
||||||
|
</NativeMenuItem>
|
||||||
|
</NativeMenu>
|
||||||
|
</NativeMenu.Menu>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<TextBlock Text="{Binding Greeting}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
<!-- 工具栏 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 扫描进度遮罩 -->
|
||||||
|
<Border Grid.ColumnSpan="5"
|
||||||
|
IsVisible="{Binding ScanProgressVM.IsScanning}"
|
||||||
|
Background="#80000000">
|
||||||
|
<views:ScanProgressView DataContext="{Binding ScanProgressVM}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 底部播放栏 -->
|
||||||
|
<Border Grid.Row="2">
|
||||||
|
<views:PlayerBarView DataContext="{Binding PlayerBarVM}"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
using GUI.ViewModels;
|
||||||
|
|
||||||
namespace GUI.Views;
|
namespace GUI.Views;
|
||||||
|
|
||||||
@@ -7,5 +10,23 @@ public partial class MainWindow : Window
|
|||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
this.ScanButton.Click += OnScanButtonClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnScanButtonClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not MainWindowViewModel vm) return;
|
||||||
|
|
||||||
|
IReadOnlyList<IStorageFolder> folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "选择音频目录",
|
||||||
|
AllowMultiple = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (folders.Count > 0)
|
||||||
|
{
|
||||||
|
string path = folders[0].Path.LocalPath;
|
||||||
|
vm.RequestScan(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace GUI.Views;
|
||||||
|
|
||||||
|
public partial class MetadataPanelView : UserControl
|
||||||
|
{
|
||||||
|
public MetadataPanelView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace GUI.Views;
|
||||||
|
|
||||||
|
public partial class PlayerBarView : UserControl
|
||||||
|
{
|
||||||
|
public PlayerBarView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:GUI.ViewModels"
|
||||||
|
xmlns:converters="using:GUI.Converters"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<converters:HalfDoubleConverter x:Key="HalfDouble"/>
|
||||||
|
<converters:MiddleTruncateConverter x:Key="MiddleTruncate"/>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Border Padding="16" Background="{DynamicResource SystemBaseLowColorBrush}" CornerRadius="8"
|
||||||
|
Width="{Binding $parent[Window].Width, Converter={StaticResource HalfDouble}}">
|
||||||
|
<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, Converter={StaticResource MiddleTruncate}, ConverterParameter=60}"
|
||||||
|
FontSize="11"/>
|
||||||
|
<Button Content="关闭" Command="{Binding DismissCommand}"
|
||||||
|
IsVisible="{Binding HasError}" HorizontalAlignment="Right"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace GUI.Views;
|
||||||
|
|
||||||
|
public partial class ScanProgressView : UserControl
|
||||||
|
{
|
||||||
|
public ScanProgressView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace GUI.Views;
|
||||||
|
|
||||||
|
public partial class TagTreeView : UserControl
|
||||||
|
{
|
||||||
|
public TagTreeView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFormat_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fe2d95a1d7b987bbb7785b2ad7e2e3e29075ba79cdf7619dd26c10d266c5_003FFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFormat_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fe2d95a1d7b987bbb7785b2ad7e2e3e29075ba79cdf7619dd26c10d266c5_003FFormat_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AGUI_002EViewModels_002EScanProgressViewModel_002Eg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fbb74b3c6eaaeb5d783f31310eae5345eba75596a_003FGUI_002EViewModels_002EScanProgressViewModel_002Eg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AGUI_002EViewModels_002EScanProgressViewModel_002EStartScan_002Eg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F1a6f588df342309187674fcc524512a9a4a46540_003FGUI_002EViewModels_002EScanProgressViewModel_002EStartScan_002Eg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AGUI_002EViewModels_002EScanProgressViewModel_002EStartScan_002Eg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003Fvar_003Ffolders_003F5b_003F4wt_005Fpwy11fz5nkdv7s47r3780000gn_003FT_003FSourceGeneratedDocuments_003F8E641B210524A46B7DCD4502_003FCommunityToolkit_002EMvvm_002ESourceGenerators_003FCommunityToolkit_002EMvvm_002ESourceGenerators_002ERelayCommandGenerator_003FGUI_002EViewModels_002EScanProgressViewModel_002EStartScan_002Eg_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AGUI_002EViewModels_002EScanProgressViewModel_002EStartScan_002Eg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F1a6f588df342309187674fcc524512a9a4a46540_003FGUI_002EViewModels_002EScanProgressViewModel_002EStartScan_002Eg_002Ecs_002Fz_003A2_002D0/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIDbConnection_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01c4805efedf4381865d701be69b5ce5314910_003F77_003Ffb273e4d_003FIDbConnection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIDbConnection_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01c4805efedf4381865d701be69b5ce5314910_003F77_003Ffb273e4d_003FIDbConnection_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIRelayCommand_007BT_007D_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F49f2a292ecb417be1f61c426de242e503b4371a346bf5b68a843b462337e62_003FIRelayCommand_007BT_007D_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOSPlatform_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F9e988c941e6515999ebab7336243c87df16e340b8faeb57b5f562d2b8a7c2c_003FOSPlatform_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOSPlatform_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F9e988c941e6515999ebab7336243c87df16e340b8faeb57b5f562d2b8a7c2c_003FOSPlatform_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATrack_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F872a20b9878a835418db772627eab53c29e04a65284443b02d35345255649a3a_003FTrack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATrack_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F872a20b9878a835418db772627eab53c29e04a65284443b02d35345255649a3a_003FTrack_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
|
||||||
@@ -23,6 +28,11 @@
|
|||||||
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.IntegratedTest.ScanReadWritePipeline_ShouldGenerateDatabase</TestId>
|
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.IntegratedTest.ScanReadWritePipeline_ShouldGenerateDatabase</TestId>
|
||||||
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.IntegratedTest</TestId>
|
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.IntegratedTest</TestId>
|
||||||
</TestAncestor>
|
</TestAncestor>
|
||||||
|
</SessionState></s:String>
|
||||||
|
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=7a0de69d_002D085d_002D444a_002D9fbe_002D33683c7b6cab/@EntryIndexedValue"><SessionState ContinuousTestingMode="0" IsActive="True" Name="DatabaseTests" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session">
|
||||||
|
<TestAncestor>
|
||||||
|
<TestId>xUnit::24F92458-FB39-44BE-A32F-41275183AF1B::net10.0::Core.Tests.DatabaseTests</TestId>
|
||||||
|
</TestAncestor>
|
||||||
</SessionState></s:String>
|
</SessionState></s:String>
|
||||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=GUI_002FAssets_002FLocale/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=GUI_002FAssets_002FLocale/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=GUI_002FLocale/@EntryIndexedValue">False</s:Boolean>
|
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=GUI_002FLocale/@EntryIndexedValue">False</s:Boolean>
|
||||||
|
|||||||
Reference in New Issue
Block a user