feat: Implement lyrics download feature.

This commit is contained in:
real-zony
2026-03-03 12:07:28 +08:00
parent 1f7414ead3
commit 4f4398acc8
10 changed files with 345 additions and 17 deletions

View File

@@ -5,6 +5,7 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Styles> </Application.Styles>
<Application.Resources> <Application.Resources>

View File

@@ -242,4 +242,8 @@
<data name="Common_Info" xml:space="preserve"> <data name="Common_Info" xml:space="preserve">
<value>Info</value> <value>Info</value>
</data> </data>
<!-- Error Messages -->
<data name="Error_NoMusicFiles" xml:space="preserve">
<value>No music files found in the selected folder.</value>
</data>
</root> </root>

View File

@@ -242,4 +242,8 @@
<data name="Common_Info" xml:space="preserve"> <data name="Common_Info" xml:space="preserve">
<value>提示</value> <value>提示</value>
</data> </data>
<!-- Error Messages -->
<data name="Error_NoMusicFiles" xml:space="preserve">
<value>在所选文件夹中没有找到任何音乐文件。</value>
</data>
</root> </root>

View File

@@ -2,6 +2,7 @@ using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using ZonyLrcTools.Desktop.Views.Dialogs;
namespace ZonyLrcTools.Desktop.Services; namespace ZonyLrcTools.Desktop.Services;
@@ -47,14 +48,18 @@ public class DialogService : IDialogService
public async Task<bool> ShowConfirmDialogAsync(string title, string message) public async Task<bool> ShowConfirmDialogAsync(string title, string message)
{ {
// TODO: Implement custom confirm dialog if (MainWindow == null) return false;
await Task.CompletedTask;
return true; var dialog = new ConfirmDialog(title, message);
var result = await dialog.ShowDialog<bool>(MainWindow);
return result;
} }
public async Task ShowMessageAsync(string title, string message) public async Task ShowMessageAsync(string title, string message)
{ {
// TODO: Implement custom message dialog if (MainWindow == null) return;
await Task.CompletedTask;
var dialog = new MessageDialog(title, message);
await dialog.ShowDialog(MainWindow);
} }
} }

View File

@@ -1,7 +1,14 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Options;
using ZonyLrcTools.Common;
using ZonyLrcTools.Common.Configuration;
using ZonyLrcTools.Common.Infrastructure.IO;
using ZonyLrcTools.Common.Infrastructure.Threading;
using ZonyLrcTools.Common.Lyrics; using ZonyLrcTools.Common.Lyrics;
using ZonyLrcTools.Common.TagInfo;
using ZonyLrcTools.Desktop.Infrastructure.Localization; using ZonyLrcTools.Desktop.Infrastructure.Localization;
using ZonyLrcTools.Desktop.Services; using ZonyLrcTools.Desktop.Services;
@@ -11,9 +18,14 @@ public partial class LyricsDownloadViewModel : ViewModelBase
{ {
private readonly ILyricsDownloader _lyricsDownloader; private readonly ILyricsDownloader _lyricsDownloader;
private readonly IDialogService _dialogService; private readonly IDialogService _dialogService;
private readonly IFileScanner _fileScanner;
private readonly ITagLoader _tagLoader;
private readonly GlobalOptions _options;
private readonly IUILocalizationService? _localization; private readonly IUILocalizationService? _localization;
private CancellationTokenSource? _downloadCts; private CancellationTokenSource? _downloadCts;
private Dictionary<string, MusicFileViewModel> _musicFileMap = new();
private List<MusicInfo> _scannedMusicInfos = new();
[ObservableProperty] [ObservableProperty]
private string? _selectedFolderPath; private string? _selectedFolderPath;
@@ -38,6 +50,21 @@ public partial class LyricsDownloadViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(CanStartDownload))] [NotifyPropertyChangedFor(nameof(CanStartDownload))]
private bool _isDownloading; private bool _isDownloading;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartDownloadCommand))]
[NotifyCanExecuteChangedFor(nameof(SelectFolderCommand))]
[NotifyPropertyChangedFor(nameof(CanStartDownload))]
private bool _isScanning;
[ObservableProperty]
private int _scanProgressCount;
[ObservableProperty]
private int _scanTotalCount;
[ObservableProperty]
private double _scanProgressPercentage;
[ObservableProperty] [ObservableProperty]
private string? _currentProcessingFile; private string? _currentProcessingFile;
@@ -49,6 +76,7 @@ public partial class LyricsDownloadViewModel : ViewModelBase
public string LyricsParallel => _localization?["Lyrics_Parallel"] ?? "Parallel:"; public string LyricsParallel => _localization?["Lyrics_Parallel"] ?? "Parallel:";
public string LyricsStartDownload => _localization?["Lyrics_StartDownload"] ?? "Start Download"; public string LyricsStartDownload => _localization?["Lyrics_StartDownload"] ?? "Start Download";
public string LyricsStopDownload => _localization?["Lyrics_StopDownload"] ?? "Stop Download"; public string LyricsStopDownload => _localization?["Lyrics_StopDownload"] ?? "Stop Download";
public string LyricsScanning => _localization?["Lyrics_Status_Scanning"] ?? "Scanning files...";
public string CommonTotal => _localization?["Common_Total"] ?? "Total:"; public string CommonTotal => _localization?["Common_Total"] ?? "Total:";
public string CommonFiles => _localization?["Common_Files"] ?? "files"; public string CommonFiles => _localization?["Common_Files"] ?? "files";
public string CommonSuccess => _localization?["Common_Success"] ?? "Success:"; public string CommonSuccess => _localization?["Common_Success"] ?? "Success:";
@@ -58,17 +86,23 @@ public partial class LyricsDownloadViewModel : ViewModelBase
public string ColumnFilePath => _localization?["Column_FilePath"] ?? "File Path"; public string ColumnFilePath => _localization?["Column_FilePath"] ?? "File Path";
public string ColumnStatus => _localization?["Column_Status"] ?? "Status"; public string ColumnStatus => _localization?["Column_Status"] ?? "Status";
public bool CanStartDownload => !IsDownloading && !string.IsNullOrEmpty(SelectedFolderPath); public bool CanStartDownload => !IsDownloading && !IsScanning && MusicFiles.Count > 0;
public ObservableCollection<MusicFileViewModel> MusicFiles { get; } = new(); public ObservableCollection<MusicFileViewModel> MusicFiles { get; } = new();
public LyricsDownloadViewModel( public LyricsDownloadViewModel(
ILyricsDownloader lyricsDownloader, ILyricsDownloader lyricsDownloader,
IDialogService dialogService, IDialogService dialogService,
IFileScanner fileScanner,
ITagLoader tagLoader,
IOptions<GlobalOptions> options,
IUILocalizationService? localization = null) IUILocalizationService? localization = null)
{ {
_lyricsDownloader = lyricsDownloader; _lyricsDownloader = lyricsDownloader;
_dialogService = dialogService; _dialogService = dialogService;
_fileScanner = fileScanner;
_tagLoader = tagLoader;
_options = options.Value;
_localization = localization; _localization = localization;
if (_localization != null) if (_localization != null)
@@ -86,6 +120,7 @@ public partial class LyricsDownloadViewModel : ViewModelBase
OnPropertyChanged(nameof(LyricsParallel)); OnPropertyChanged(nameof(LyricsParallel));
OnPropertyChanged(nameof(LyricsStartDownload)); OnPropertyChanged(nameof(LyricsStartDownload));
OnPropertyChanged(nameof(LyricsStopDownload)); OnPropertyChanged(nameof(LyricsStopDownload));
OnPropertyChanged(nameof(LyricsScanning));
OnPropertyChanged(nameof(CommonTotal)); OnPropertyChanged(nameof(CommonTotal));
OnPropertyChanged(nameof(CommonFiles)); OnPropertyChanged(nameof(CommonFiles));
OnPropertyChanged(nameof(CommonSuccess)); OnPropertyChanged(nameof(CommonSuccess));
@@ -96,32 +131,155 @@ public partial class LyricsDownloadViewModel : ViewModelBase
OnPropertyChanged(nameof(ColumnStatus)); OnPropertyChanged(nameof(ColumnStatus));
} }
[RelayCommand] [RelayCommand(CanExecute = nameof(CanSelectFolder))]
private async Task SelectFolderAsync() private async Task SelectFolderAsync()
{ {
var folder = await _dialogService.ShowFolderPickerAsync("Select Music Folder"); var folder = await _dialogService.ShowFolderPickerAsync(
_localization?["Lyrics_SelectFolder"] ?? "Select Music Folder");
if (string.IsNullOrEmpty(folder)) return; if (string.IsNullOrEmpty(folder)) return;
SelectedFolderPath = folder; SelectedFolderPath = folder;
await ScanFilesAsync();
}
private bool CanSelectFolder => !IsScanning && !IsDownloading;
private async Task ScanFilesAsync()
{
if (string.IsNullOrEmpty(SelectedFolderPath)) return;
IsScanning = true;
MusicFiles.Clear();
_musicFileMap.Clear();
_scannedMusicInfos.Clear();
ScanProgressCount = 0;
ScanTotalCount = 0;
ScanProgressPercentage = 0;
TotalCount = 0;
CompletedCount = 0;
FailedCount = 0;
ProgressPercentage = 0;
try
{
var files = (await _fileScanner.ScanMusicFilesAsync(SelectedFolderPath, _options.SupportFileExtensions))
.ToList();
if (files.Count == 0)
{
await _dialogService.ShowMessageAsync(
_localization?["Common_Info"] ?? "Info",
_localization?["Error_NoMusicFiles"] ?? "No music files found in the selected folder.");
IsScanning = false;
OnPropertyChanged(nameof(CanStartDownload)); OnPropertyChanged(nameof(CanStartDownload));
return;
}
ScanTotalCount = files.Count;
using var warpTask = new WarpTask(ParallelCount);
var scanTasks = files.Select(file =>
warpTask.RunAsync(async () =>
{
var musicInfo = await _tagLoader.LoadTagAsync(file);
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (musicInfo != null &&
(!string.IsNullOrEmpty(musicInfo.Name) || !string.IsNullOrEmpty(musicInfo.Artist)))
{
var vm = new MusicFileViewModel
{
FilePath = musicInfo.FilePath,
Name = musicInfo.Name,
Artist = musicInfo.Artist,
StatusMessage = _localization?["Status_Pending"] ?? "Pending"
};
MusicFiles.Add(vm);
_musicFileMap[musicInfo.FilePath] = vm;
_scannedMusicInfos.Add(musicInfo);
}
ScanProgressCount++;
ScanProgressPercentage = ScanProgressCount * 100.0 / ScanTotalCount;
});
}));
await Task.WhenAll(scanTasks);
TotalCount = MusicFiles.Count;
}
catch (Exception ex)
{
await _dialogService.ShowMessageAsync(
_localization?["Common_Error"] ?? "Error",
ex.Message);
}
finally
{
IsScanning = false;
OnPropertyChanged(nameof(CanStartDownload));
}
} }
[RelayCommand(CanExecute = nameof(CanStartDownload))] [RelayCommand(CanExecute = nameof(CanStartDownload))]
private async Task StartDownloadAsync() private async Task StartDownloadAsync()
{ {
if (string.IsNullOrEmpty(SelectedFolderPath)) return; if (_scannedMusicInfos.Count == 0) return;
IsDownloading = true; IsDownloading = true;
CompletedCount = 0; CompletedCount = 0;
FailedCount = 0; FailedCount = 0;
ProgressPercentage = 0; ProgressPercentage = 0;
TotalCount = _scannedMusicInfos.Count;
// Reset all row statuses
foreach (var vm in MusicFiles)
{
vm.IsProcessed = false;
vm.IsSuccessful = false;
vm.StatusMessage = _localization?["Status_Pending"] ?? "Pending";
}
_downloadCts = new CancellationTokenSource(); _downloadCts = new CancellationTokenSource();
try try
{ {
// TODO: Implement actual download logic using _lyricsDownloader await _lyricsDownloader.DownloadAsync(
await Task.Delay(1000, _downloadCts.Token); // Placeholder _scannedMusicInfos,
ParallelCount,
async musicInfo =>
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (_musicFileMap.TryGetValue(musicInfo.FilePath, out var vm))
{
vm.IsProcessed = true;
vm.IsSuccessful = musicInfo.IsSuccessful;
if (musicInfo.IsPruneMusic)
{
vm.StatusMessage = _localization?["Status_Skipped"] ?? "Skipped";
}
else if (musicInfo.IsSuccessful)
{
vm.StatusMessage = _localization?["Status_Success"] ?? "Success";
}
else
{
vm.StatusMessage = _localization?["Status_Failed"] ?? "Failed";
}
}
if (musicInfo.IsSuccessful)
CompletedCount++;
else
FailedCount++;
ProgressPercentage = (CompletedCount + FailedCount) * 100.0 / TotalCount;
});
},
_downloadCts.Token);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

View File

@@ -0,0 +1,39 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ZonyLrcTools.Desktop.Views.Dialogs.ConfirmDialog"
Title=""
Width="400"
Height="200"
CanResize="False"
WindowStartupLocation="CenterOwner"
SizeToContent="Height">
<Grid Margin="24" RowDefinitions="Auto,*,Auto">
<!-- Title -->
<TextBlock Grid.Row="0"
x:Name="TitleText"
FontSize="16"
FontWeight="SemiBold"
Margin="0,0,0,12" />
<!-- Message -->
<TextBlock Grid.Row="1"
x:Name="MessageText"
TextWrapping="Wrap"
Margin="0,0,0,20" />
<!-- Buttons -->
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8">
<Button x:Name="CancelButton"
MinWidth="80"
Click="OnCancelClick" />
<Button x:Name="ConfirmButton"
MinWidth="80"
Classes="accent"
Click="OnConfirmClick" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,35 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Microsoft.Extensions.DependencyInjection;
using ZonyLrcTools.Desktop.Infrastructure.Localization;
namespace ZonyLrcTools.Desktop.Views.Dialogs;
public partial class ConfirmDialog : Window
{
public ConfirmDialog()
{
InitializeComponent();
}
public ConfirmDialog(string title, string message) : this()
{
TitleText.Text = title;
MessageText.Text = message;
Title = title;
var localization = App.Services?.GetService<IUILocalizationService>();
ConfirmButton.Content = localization?["Common_OK"] ?? "OK";
CancelButton.Content = localization?["Common_Cancel"] ?? "Cancel";
}
private void OnConfirmClick(object? sender, RoutedEventArgs e)
{
Close(true);
}
private void OnCancelClick(object? sender, RoutedEventArgs e)
{
Close(false);
}
}

View File

@@ -0,0 +1,32 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ZonyLrcTools.Desktop.Views.Dialogs.MessageDialog"
Title=""
Width="400"
Height="200"
CanResize="False"
WindowStartupLocation="CenterOwner"
SizeToContent="Height">
<Grid Margin="24" RowDefinitions="Auto,*,Auto">
<!-- Title -->
<TextBlock Grid.Row="0"
x:Name="TitleText"
FontSize="16"
FontWeight="SemiBold"
Margin="0,0,0,12" />
<!-- Message -->
<TextBlock Grid.Row="1"
x:Name="MessageText"
TextWrapping="Wrap"
Margin="0,0,0,20" />
<!-- OK Button -->
<Button Grid.Row="2"
x:Name="OkButton"
HorizontalAlignment="Right"
MinWidth="80"
Click="OnOkClick" />
</Grid>
</Window>

View File

@@ -0,0 +1,29 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Microsoft.Extensions.DependencyInjection;
using ZonyLrcTools.Desktop.Infrastructure.Localization;
namespace ZonyLrcTools.Desktop.Views.Dialogs;
public partial class MessageDialog : Window
{
public MessageDialog()
{
InitializeComponent();
}
public MessageDialog(string title, string message) : this()
{
TitleText.Text = title;
MessageText.Text = message;
Title = title;
var localization = App.Services?.GetService<IUILocalizationService>();
OkButton.Content = localization?["Common_OK"] ?? "OK";
}
private void OnOkClick(object? sender, RoutedEventArgs e)
{
Close();
}
}

View File

@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl 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:ZonyLrcTools.Desktop.ViewModels" xmlns:vm="using:ZonyLrcTools.Desktop.ViewModels"
xmlns:loc="using:ZonyLrcTools.Desktop.Infrastructure.Localization"
x:Class="ZonyLrcTools.Desktop.Views.Pages.LyricsDownloadPage" x:Class="ZonyLrcTools.Desktop.Views.Pages.LyricsDownloadPage"
x:DataType="vm:LyricsDownloadViewModel"> x:DataType="vm:LyricsDownloadViewModel">
@@ -56,6 +57,7 @@
<Button Command="{Binding StartDownloadCommand}" <Button Command="{Binding StartDownloadCommand}"
Content="{Binding LyricsStartDownload}" Content="{Binding LyricsStartDownload}"
IsVisible="{Binding !IsDownloading}" IsVisible="{Binding !IsDownloading}"
IsEnabled="{Binding CanStartDownload}"
Classes="accent" Classes="accent"
MinWidth="100" /> MinWidth="100" />
<Button Command="{Binding CancelDownloadCommand}" <Button Command="{Binding CancelDownloadCommand}"
@@ -65,7 +67,21 @@
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- Progress Bar (visible during download) --> <!-- Progress Bar: Scanning phase -->
<StackPanel IsVisible="{Binding IsScanning}" Spacing="8">
<ProgressBar Value="{Binding ScanProgressPercentage}"
Maximum="100"
Height="6" />
<TextBlock HorizontalAlignment="Center" FontSize="12" Opacity="0.8">
<Run Text="{Binding LyricsScanning}" />
<Run Text=" " />
<Run Text="{Binding ScanProgressCount}" />
<Run Text=" / " />
<Run Text="{Binding ScanTotalCount}" />
</TextBlock>
</StackPanel>
<!-- Progress Bar: Download phase -->
<StackPanel IsVisible="{Binding IsDownloading}" Spacing="8"> <StackPanel IsVisible="{Binding IsDownloading}" Spacing="8">
<ProgressBar Value="{Binding ProgressPercentage}" <ProgressBar Value="{Binding ProgressPercentage}"
Maximum="100" Maximum="100"
@@ -90,20 +106,25 @@
GridLinesVisibility="Horizontal" GridLinesVisibility="Horizontal"
BorderThickness="0"> BorderThickness="0">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="{Binding ColumnSongName}" <DataGridTextColumn x:DataType="vm:MusicFileViewModel"
Header="{loc:Localize Column_SongName}"
Binding="{Binding Name}" Binding="{Binding Name}"
Width="*" /> Width="*" />
<DataGridTextColumn Header="{Binding ColumnArtist}" <DataGridTextColumn x:DataType="vm:MusicFileViewModel"
Header="{loc:Localize Column_Artist}"
Binding="{Binding Artist}" Binding="{Binding Artist}"
Width="150" /> Width="150" />
<DataGridTextColumn Header="{Binding ColumnFilePath}" <DataGridTextColumn x:DataType="vm:MusicFileViewModel"
Header="{loc:Localize Column_FilePath}"
Binding="{Binding FilePath}" Binding="{Binding FilePath}"
Width="250" /> Width="250" />
<DataGridTextColumn Header="{Binding ColumnStatus}" <DataGridTextColumn x:DataType="vm:MusicFileViewModel"
Header="{loc:Localize Column_Status}"
Binding="{Binding StatusMessage}" Binding="{Binding StatusMessage}"
Width="100" /> Width="100" />
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
</Border> </Border>
<!-- Status Bar --> <!-- Status Bar -->