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>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Styles>
<Application.Resources>

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,14 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
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.TagInfo;
using ZonyLrcTools.Desktop.Infrastructure.Localization;
using ZonyLrcTools.Desktop.Services;
@@ -11,9 +18,14 @@ public partial class LyricsDownloadViewModel : ViewModelBase
{
private readonly ILyricsDownloader _lyricsDownloader;
private readonly IDialogService _dialogService;
private readonly IFileScanner _fileScanner;
private readonly ITagLoader _tagLoader;
private readonly GlobalOptions _options;
private readonly IUILocalizationService? _localization;
private CancellationTokenSource? _downloadCts;
private Dictionary<string, MusicFileViewModel> _musicFileMap = new();
private List<MusicInfo> _scannedMusicInfos = new();
[ObservableProperty]
private string? _selectedFolderPath;
@@ -38,6 +50,21 @@ public partial class LyricsDownloadViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(CanStartDownload))]
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]
private string? _currentProcessingFile;
@@ -49,6 +76,7 @@ public partial class LyricsDownloadViewModel : ViewModelBase
public string LyricsParallel => _localization?["Lyrics_Parallel"] ?? "Parallel:";
public string LyricsStartDownload => _localization?["Lyrics_StartDownload"] ?? "Start Download";
public string LyricsStopDownload => _localization?["Lyrics_StopDownload"] ?? "Stop Download";
public string LyricsScanning => _localization?["Lyrics_Status_Scanning"] ?? "Scanning files...";
public string CommonTotal => _localization?["Common_Total"] ?? "Total:";
public string CommonFiles => _localization?["Common_Files"] ?? "files";
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 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 LyricsDownloadViewModel(
ILyricsDownloader lyricsDownloader,
IDialogService dialogService,
IFileScanner fileScanner,
ITagLoader tagLoader,
IOptions<GlobalOptions> options,
IUILocalizationService? localization = null)
{
_lyricsDownloader = lyricsDownloader;
_dialogService = dialogService;
_fileScanner = fileScanner;
_tagLoader = tagLoader;
_options = options.Value;
_localization = localization;
if (_localization != null)
@@ -86,6 +120,7 @@ public partial class LyricsDownloadViewModel : ViewModelBase
OnPropertyChanged(nameof(LyricsParallel));
OnPropertyChanged(nameof(LyricsStartDownload));
OnPropertyChanged(nameof(LyricsStopDownload));
OnPropertyChanged(nameof(LyricsScanning));
OnPropertyChanged(nameof(CommonTotal));
OnPropertyChanged(nameof(CommonFiles));
OnPropertyChanged(nameof(CommonSuccess));
@@ -96,32 +131,155 @@ public partial class LyricsDownloadViewModel : ViewModelBase
OnPropertyChanged(nameof(ColumnStatus));
}
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanSelectFolder))]
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;
SelectedFolderPath = folder;
OnPropertyChanged(nameof(CanStartDownload));
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));
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))]
private async Task StartDownloadAsync()
{
if (string.IsNullOrEmpty(SelectedFolderPath)) return;
if (_scannedMusicInfos.Count == 0) return;
IsDownloading = true;
CompletedCount = 0;
FailedCount = 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();
try
{
// TODO: Implement actual download logic using _lyricsDownloader
await Task.Delay(1000, _downloadCts.Token); // Placeholder
await _lyricsDownloader.DownloadAsync(
_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)
{

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"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ZonyLrcTools.Desktop.ViewModels"
xmlns:loc="using:ZonyLrcTools.Desktop.Infrastructure.Localization"
x:Class="ZonyLrcTools.Desktop.Views.Pages.LyricsDownloadPage"
x:DataType="vm:LyricsDownloadViewModel">
@@ -56,6 +57,7 @@
<Button Command="{Binding StartDownloadCommand}"
Content="{Binding LyricsStartDownload}"
IsVisible="{Binding !IsDownloading}"
IsEnabled="{Binding CanStartDownload}"
Classes="accent"
MinWidth="100" />
<Button Command="{Binding CancelDownloadCommand}"
@@ -65,7 +67,21 @@
</StackPanel>
</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">
<ProgressBar Value="{Binding ProgressPercentage}"
Maximum="100"
@@ -90,20 +106,25 @@
GridLinesVisibility="Horizontal"
BorderThickness="0">
<DataGrid.Columns>
<DataGridTextColumn Header="{Binding ColumnSongName}"
<DataGridTextColumn x:DataType="vm:MusicFileViewModel"
Header="{loc:Localize Column_SongName}"
Binding="{Binding Name}"
Width="*" />
<DataGridTextColumn Header="{Binding ColumnArtist}"
<DataGridTextColumn x:DataType="vm:MusicFileViewModel"
Header="{loc:Localize Column_Artist}"
Binding="{Binding Artist}"
Width="150" />
<DataGridTextColumn Header="{Binding ColumnFilePath}"
<DataGridTextColumn x:DataType="vm:MusicFileViewModel"
Header="{loc:Localize Column_FilePath}"
Binding="{Binding FilePath}"
Width="250" />
<DataGridTextColumn Header="{Binding ColumnStatus}"
<DataGridTextColumn x:DataType="vm:MusicFileViewModel"
Header="{loc:Localize Column_Status}"
Binding="{Binding StatusMessage}"
Width="100" />
</DataGrid.Columns>
</DataGrid>
</Border>
<!-- Status Bar -->