Compare commits

..

10 Commits

Author SHA1 Message Date
Zhe Fang
aa3e79d3ff chores: i18n 2025-12-21 13:28:22 -05:00
Zhe Fang
9979474ce1 fix 2025-12-21 12:24:46 -05:00
Zhe Fang
2e7cd93cfe fix: renderer error 2025-12-21 08:04:26 -05:00
Zhe Fang
bdc31c3e0d fix: lyrics source search issue when id is not null; feat: support ftp, smb, webdav (still testing) 2025-12-21 06:34:11 -05:00
Zhe Fang
631d079aa2 chores: adjustment for album art info flyout 2025-12-19 16:29:36 -05:00
Zhe Fang
f76ef87167 feat: save album art to local 2025-12-19 16:10:14 -05:00
Zhe Fang
76aa5ee8d0 fix: search control result can not invoke play selected lyrics line 2025-12-19 14:46:23 -05:00
Zhe Fang
d7f4978a66 chores: update deps 2025-12-19 09:36:53 -05:00
Zhe Fang
0905c46e45 fix: scroll 2025-12-19 08:54:24 -05:00
Zhe Fang
d0991c5ddb fix: 3d lyrics effect incorrect y center 2025-12-17 20:22:26 -05:00
54 changed files with 1590 additions and 282 deletions

View File

@@ -12,7 +12,7 @@
<Identity
Name="37412.BetterLyrics"
Publisher="CN=E1428B0E-DC1D-4EA4-ACB1-4556569D5BA9"
Version="1.1.198.0" />
Version="1.1.203.0" />
<mp:PhoneIdentity PhoneProductId="ca4a4830-fc19-40d9-b823-53e2bff3d816" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>

View File

@@ -65,7 +65,6 @@
<converter:ByteArrayToImageConverter x:Key="ByteArrayToImageConverter" />
<converter:DisplayLanguageCodeToIndexConverter x:Key="DisplayLanguageCodeToIndexConverter" />
<converter:PathToParentFolderConverter x:Key="PathToParentFolderConverter" />
<converter:TrackToLyricsConverter x:Key="TrackToLyricsConverter" />
<converter:IntToBoolConverter x:Key="IntToBoolConverter" />
<converter:IndexToDisplayConverter x:Key="IndexToDisplayConverter" />
<converter:IntToDoubleConverter x:Key="IntToDoubleConverter" />
@@ -75,6 +74,7 @@
<converter:TextAlignmentTypeToHorizontalAlignmentConverter x:Key="TextAlignmentTypeToHorizontalAlignmentConverter" />
<converter:LyricsLayoutOrientationToOrientationConverter x:Key="LyricsLayoutOrientationToOrientationConverter" />
<converter:LyricsLayoutOrientationNegationToOrientationConverter x:Key="LyricsLayoutOrientationNegationToOrientationConverter" />
<converter:FileSourceTypeToIconConverter x:Key="FileSourceTypeToIconConverter" />
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<converters:BoolNegationConverter x:Key="BoolNegationConverter" />

View File

@@ -37,6 +37,7 @@
<None Remove="Controls\NowPlayingBar.xaml" />
<None Remove="Controls\PlaybackSettingsControl.xaml" />
<None Remove="Controls\PropertyRow.xaml" />
<None Remove="Controls\RemoteServerConfigControl.xaml" />
<None Remove="Controls\ShortcutTextBox.xaml" />
<None Remove="Controls\SystemTray.xaml" />
<None Remove="Controls\WindowSettingsControl.xaml" />
@@ -58,20 +59,21 @@
<PackageReference Include="CommunityToolkit.Labs.WinUI.Controls.OpacityMaskView" Version="0.1.251021-build.2365" />
<PackageReference Include="CommunityToolkit.Labs.WinUI.Shimmer" Version="0.1.250703-build.2173" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.250402" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.250402" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Segmented" Version="8.2.250402" />
<PackageReference Include="CommunityToolkit.WinUI.Converters" Version="8.2.250402" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" />
<PackageReference Include="CommunityToolkit.WinUI.Helpers" Version="8.2.250402" />
<PackageReference Include="CommunityToolkit.WinUI.Media" Version="8.2.250402" />
<PackageReference Include="CommunityToolkit.WinUI.Triggers" Version="8.2.250402" />
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.251219" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Segmented" Version="8.2.251219" />
<PackageReference Include="CommunityToolkit.WinUI.Converters" Version="8.2.251219" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
<PackageReference Include="CommunityToolkit.WinUI.Helpers" Version="8.2.251219" />
<PackageReference Include="CommunityToolkit.WinUI.Media" Version="8.2.251219" />
<PackageReference Include="CommunityToolkit.WinUI.Triggers" Version="8.2.251219" />
<PackageReference Include="ComputeSharp.D2D1.WinUI" Version="3.2.0" />
<PackageReference Include="csharp-pinyin" Version="1.0.1" />
<PackageReference Include="DevWinUI.Controls" Version="9.7.1" />
<PackageReference Include="DevWinUI.Controls" Version="9.8.1" />
<PackageReference Include="Dubya.WindowsMediaController" Version="2.5.6" />
<PackageReference Include="F23.StringSimilarity" Version="7.0.1" />
<PackageReference Include="FlaUI.UIA3" Version="5.0.0" />
<PackageReference Include="FluentFTP" Version="53.0.2" />
<PackageReference Include="H.NotifyIcon.WinUI" Version="2.4.1" />
<PackageReference Include="Hqub.Last.fm" Version="2.5.1" />
<PackageReference Include="Interop.UIAutomationClient" Version="10.19041.0" />
@@ -85,6 +87,7 @@
<PackageReference Include="NTextCat" Version="0.3.65" />
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="SMBLibrary" Version="1.5.5.1" />
<PackageReference Include="System.Drawing.Common" Version="10.0.1" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.1" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
@@ -95,6 +98,7 @@
<PackageReference Include="Vanara.PInvoke.User32" Version="4.2.1" />
<PackageReference Include="Vanara.Windows.Shell" Version="4.2.1" />
<PackageReference Include="VCollab.DiscordRichPresence" Version="1.7.0" />
<PackageReference Include="WebDav.Client" Version="2.9.0" />
<PackageReference Include="WinUIEx" Version="2.9.0" />
<PackageReference Include="z440.atl.core" Version="7.9.0" />
</ItemGroup>
@@ -339,6 +343,11 @@
<ItemGroup>
<Folder Include="TemplateSelector\" />
</ItemGroup>
<ItemGroup>
<Page Update="Controls\RemoteServerConfigControl.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="Controls\NowPlayingBar.xaml">
<Generator>MSBuild:Compile</Generator>

View File

@@ -548,11 +548,12 @@ namespace BetterLyrics.WinUI3.Controls
_isMouseScrollingChanged = false;
_lyricsRenderer.CalculateLyrics3DMatrix(
lyricsStyle: lyricsStyle,
lyricsEffect: lyricsEffect,
lyricsX: _renderLyricsStartX,
lyricsY: _renderLyricsStartY,
lyricsWidth: _renderLyricsWidth,
canvasHeight: sender.Size.Height
lyricsHeight: _renderLyricsHeight
);
_isLayoutChanged = false;

View File

@@ -278,18 +278,43 @@
</Pivot.HeaderTemplate>
<Pivot.ItemTemplate>
<DataTemplate x:DataType="models:LyricsData">
<ListView ItemsSource="{x:Bind LyricsLines, Mode=OneWay}" SelectionChanged="ListView_SelectionChanged">
<ListView
ItemContainerStyle="{StaticResource ListViewStretchedItemContainerStyle}"
ItemsSource="{x:Bind LyricsLines, Mode=OneWay}"
SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:LyricsLine">
<StackPanel Orientation="Horizontal">
<TextBlock Foreground="{ThemeResource SystemFillColorNeutralBrush}" Text="{x:Bind StartMs, Mode=OneWay, Converter={StaticResource MillisecondsToFormattedTimeConverter}}" />
<TextBlock
Margin="1,0"
Foreground="{ThemeResource SystemFillColorNeutralBrush}"
Text="-" />
<TextBlock Foreground="{ThemeResource SystemFillColorNeutralBrush}" Text="{x:Bind EndMs, Mode=OneWay, Converter={StaticResource MillisecondsToFormattedTimeConverter}}" />
<TextBlock Margin="6,0" Text="{x:Bind OriginalText, Mode=OneWay}" />
</StackPanel>
<Grid Margin="0,6" ColumnSpacing="6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid Grid.Column="0">
<TextBlock Foreground="{ThemeResource SystemFillColorNeutralBrush}" Text="{x:Bind StartMs, Mode=OneWay, Converter={StaticResource MillisecondsToFormattedTimeConverter}}" />
<Button
HorizontalAlignment="Center"
VerticalAlignment="Center"
Click="PlayLyricsLineButton_Click"
Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily},
FontSize=16,
Glyph=&#xE768;}"
Opacity="0"
Style="{StaticResource AccentButtonStyle}">
<Button.OpacityTransition>
<ScalarTransition />
</Button.OpacityTransition>
<interactivity:Interaction.Behaviors>
<interactivity:EventTriggerBehavior EventName="PointerEntered">
<interactivity:ChangePropertyAction PropertyName="Opacity" Value="1" />
</interactivity:EventTriggerBehavior>
<interactivity:EventTriggerBehavior EventName="PointerExited">
<interactivity:ChangePropertyAction PropertyName="Opacity" Value="0" />
</interactivity:EventTriggerBehavior>
</interactivity:Interaction.Behaviors>
</Button>
</Grid>
<local:PropertyRow Grid.Column="1" Value="{x:Bind OriginalText, Mode=OneWay}" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>

View File

@@ -18,10 +18,10 @@ namespace BetterLyrics.WinUI3.Controls
DataContext = Ioc.Default.GetRequiredService<LyricsSearchControlViewModel>();
}
private void ListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
private void PlayLyricsLineButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
ViewModel.SelectedLyricsLine = e.OriginalSource as LyricsLine;
var lyricsLine = (LyricsLine)((Button)sender).DataContext;
ViewModel.PlayLyricsLine(lyricsLine);
}
}
}

View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="BetterLyrics.WinUI3.Controls.MediaSettingsControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
@@ -9,6 +8,7 @@
xmlns:interactivity="using:Microsoft.Xaml.Interactivity"
xmlns:local="using:BetterLyrics.WinUI3.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:BetterLyrics.WinUI3.Models"
xmlns:ui="using:CommunityToolkit.WinUI"
mc:Ignorable="d">
@@ -49,25 +49,32 @@
ItemsSource="{x:Bind ViewModel.AppSettings.LocalMediaFolders, Mode=OneWay}"
SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate>
<dev:SettingsExpander>
<DataTemplate x:DataType="models:MediaFolder">
<dev:SettingsExpander Description="{x:Bind ConnectionSummary, Mode=OneWay}" HeaderIcon="{Binding SourceType, Converter={StaticResource FileSourceTypeToIconConverter}}">
<dev:SettingsExpander.Header>
<HyperlinkButton
Padding="0"
Click="LocalFolderHyperlinkButton_Click"
Content="{Binding Path, Mode=OneWay}"
Tag="{Binding Path, Mode=OneWay}" />
Content="{x:Bind Path, Mode=OneWay}"
Tag="{x:Bind Path, Mode=OneWay}"
ToolTipService.ToolTip="{x:Bind ConnectionSummary}" />
</dev:SettingsExpander.Header>
<ToggleSwitch IsOn="{Binding IsEnabled, Mode=TwoWay}" />
<ToggleSwitch IsOn="{x:Bind IsEnabled, Mode=TwoWay}" />
<dev:SettingsExpander.Items>
<dev:SettingsCard>
<dev:SettingsCard.Header>
<HyperlinkButton
x:Uid="SettingsPageRemovePath"
Padding="0"
Click="SettingsPageRemovePathButton_Click"
Tag="{Binding}" />
</dev:SettingsCard.Header>
</dev:SettingsCard>
<dev:SettingsCard x:Uid="SettingsPageMusicLibRealTimeWatch">
<dev:SettingsCard x:Uid="SettingsPageMusicLibRealTimeWatch" IsEnabled="{Binding IsLocal, Mode=OneWay}">
<ToggleSwitch IsOn="{Binding IsRealTimeWatchEnabled, Mode=TwoWay}" />
</dev:SettingsCard>
</dev:SettingsExpander.Items>
@@ -76,15 +83,52 @@
</ListView.ItemTemplate>
</ListView>
<dev:SettingsCard x:Uid="SettingsPageAddFolder" Style="{StaticResource DefaultSettingsExpanderItemStyle}">
<Button
x:Uid="SettingsPageAddFolderButton"
Command="{x:Bind ViewModel.SelectAndAddFolderCommand}"
CommandParameter="{Binding ElementName=RootGrid}" />
<dev:SettingsCard Style="{StaticResource DefaultSettingsExpanderItemStyle}">
<DropDownButton x:Uid="SettingsPageAddFolderButton">
<DropDownButton.Flyout>
<MenuFlyout>
<MenuFlyoutItem
x:Uid="SettingsPageLocalFolder"
Command="{x:Bind ViewModel.SelectAndAddFolderCommand}"
CommandParameter="{Binding ElementName=RootGrid}"
Icon="Folder" />
<MenuFlyoutSeparator />
<MenuFlyoutItem
Command="{x:Bind ViewModel.AddRemoteSourceCommand}"
CommandParameter="SMB"
Text="SMB">
<MenuFlyoutItem.Icon>
<FontIcon FontFamily="{StaticResource IconFontFamily}" Glyph="&#xE839;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem
Command="{x:Bind ViewModel.AddRemoteSourceCommand}"
CommandParameter="FTP"
Text="FTP">
<MenuFlyoutItem.Icon>
<FontIcon FontFamily="{StaticResource IconFontFamily}" Glyph="&#xE838;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem
Command="{x:Bind ViewModel.AddRemoteSourceCommand}"
CommandParameter="WebDAV"
Text="WebDAV">
<MenuFlyoutItem.Icon>
<FontIcon FontFamily="{StaticResource IconFontFamily}" Glyph="&#xE774;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
</MenuFlyout>
</DropDownButton.Flyout>
</DropDownButton>
</dev:SettingsCard>
</StackPanel>
</Grid>
</ScrollViewer>
</Grid>
</UserControl>
</UserControl>

View File

@@ -22,7 +22,7 @@ namespace BetterLyrics.WinUI3.Controls
private void SettingsPageRemovePathButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
ViewModel.RemoveFolderAsync((LocalMediaFolder)(sender as HyperlinkButton)!.Tag);
ViewModel.RemoveFolderAsync((MediaFolder)(sender as HyperlinkButton)!.Tag);
}
private async void LocalFolderHyperlinkButton_Click(object sender, RoutedEventArgs e)

View File

@@ -356,7 +356,7 @@
Padding="8,4"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Background="{ThemeResource AcrylicInAppFillColorDefaultBrush}"
Background="{ThemeResource LayerOnMicaBaseAltFillColorDefaultBrush}"
CornerRadius="6"
Opacity="{x:Bind ViewModel.TimelineSliderThumbOpacity, Mode=OneWay}">
<Grid.OpacityTransition>

View File

@@ -190,7 +190,7 @@ public sealed partial class NowPlayingBar : UserControl,
private void TimelineSliderOverlay_PointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
{
ViewModel.TimelineSliderThumbOpacity = 0.7f;
ViewModel.TimelineSliderThumbOpacity = 1f;
}
private void TimelineSliderOverlay_PointerExited(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="BetterLyrics.WinUI3.Controls.RemoteServerConfigControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:BetterLyrics.WinUI3.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid>
<StackPanel Width="400" Spacing="16">
<ProgressBar
x:Name="ProgressBar"
IsIndeterminate="True"
Visibility="Collapsed" />
<InfoBar
x:Name="ErrorInfoBar"
IsClosable="True"
IsOpen="False"
Severity="Error" />
<Grid ColumnDefinitions="*, Auto" ColumnSpacing="12">
<TextBox
x:Name="HostBox"
x:Uid="RemoteServerConfigControlServerAddress"
Grid.Column="0"
Header="Server Address"
InputScope="Url"
PlaceholderText="192.168.1.x"
TextWrapping="Wrap" />
<NumberBox
x:Name="PortBox"
x:Uid="RemoteServerConfigControlPort"
Grid.Column="1"
MinWidth="100"
Header="Port"
LargeChange="10"
SmallChange="1"
SpinButtonPlacementMode="Inline"
ToolTipService.ToolTip="80"
Value="80" />
</Grid>
<TextBox
x:Name="PathBox"
x:Uid="RemoteServerConfigControlPath"
TextWrapping="Wrap" />
<Grid ColumnDefinitions="*, *" ColumnSpacing="12">
<TextBox
x:Name="UserBox"
x:Uid="RemoteServerConfigControlUsername"
Grid.Column="0"
TextWrapping="Wrap" />
<PasswordBox
x:Name="PwdBox"
x:Uid="RemoteServerConfigControlPassword"
Grid.Column="1"
PasswordRevealMode="Peek" />
</Grid>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,110 @@
using BetterLyrics.WinUI3.Enums;
using BetterLyrics.WinUI3.Helper;
using BetterLyrics.WinUI3.Models;
using BetterLyrics.WinUI3.Services.ResourceService;
using CommunityToolkit.Mvvm.DependencyInjection;
using DevWinUI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using Windows.Storage;
namespace BetterLyrics.WinUI3.Controls
{
public sealed partial class RemoteServerConfigControl : UserControl
{
private readonly string _protocolType;
private readonly IResourceService _resourceService = Ioc.Default.GetRequiredService<IResourceService>();
public RemoteServerConfigControl(string protocolType)
{
this.InitializeComponent();
_protocolType = protocolType;
SetupDefaults();
}
private void SetupDefaults()
{
switch (_protocolType.ToUpper())
{
case "SMB":
PortBox.Value = 445; // SMB Ĭ<>϶˿<CFB6>
PathBox.PlaceholderText = "SharedMusic";
break;
case "FTP":
PortBox.Value = 21; // FTP Ĭ<>϶˿<CFB6>
PathBox.PlaceholderText = "/pub/music";
break;
case "WEBDAV":
PortBox.Value = 80; // WebDAV Ĭ<>϶˿<CFB6>
PathBox.PlaceholderText = "/dav/music";
break;
}
}
public MediaFolder GetConfig()
{
if (string.IsNullOrWhiteSpace(HostBox.Text))
throw new ArgumentException(_resourceService.GetLocalizedString("RemoteServerConfigControlServerAddressRequired"));
string name = $"{_protocolType} - {HostBox.Text}";
Enum.TryParse(_protocolType, true, out FileSourceType sourceType);
var folder = new MediaFolder
{
Name = name,
Path = HostBox.Text, // <20><><EFBFBD><EFBFBD> Path <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> IP/Host
Port = (int)PortBox.Value,
UserName = UserBox.Text,
Password = PwdBox.Password, // <20><> PasswordBox <20><>ȡ<EFBFBD><C8A1><EFBFBD><EFBFBD>
SourceType = sourceType,
IsRealTimeWatchEnabled = false
};
// <20><><EFBFBD><EFBFBD><E2B4A6>·<EFBFBD><C2B7><EFBFBD><EFBFBD>
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ҫ<EFBFBD><D2AA><><D4B6>·<EFBFBD><C2B7><>ӵ<EFBFBD> Path <20><EFBFBD><EFA3AC><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>һ<EFBFBD><D2BB><EFBFBD>ֶδ<D6B6>
// Ϊ<>˼򵥣<CBBC><F2B5A5A3><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ѭ<EFBFBD><D1AD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> MediaFolder <20><><EFBFBD>
// <20><><EFBFBD><EFBFBD> MediaFolder <20><><EFBFBD><EFBFBD><EFBFBD>ټ<EFBFBD>һ<EFBFBD><D2BB> RemotePath <20>ֶΣ<D6B6><CEA3><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Path <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
// *<2A><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>*<2A><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> MediaFolder <20><><EFBFBD><EFBFBD><E5A3AC><EFBFBD>ǿ<EFBFBD><C7BF><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Լ<EFBFBD><D4BC><EFBFBD><EFBFBD>
// Path <20>ֶδ洢<CEB4><E6B4A2>ʽ<EFBFBD><CABD> "192.168.1.5/Music"
var rawPath = PathBox.Text.Trim().TrimStart('/', '\\'); // ȥ<><C8A5><EFBFBD><EFBFBD>ͷ<EFBFBD><CDB7>б<EFBFBD><D0B1>
if (!string.IsNullOrEmpty(rawPath))
{
// <20>򵥵<EFBFBD>·<EFBFBD><C2B7>ƴ<EFBFBD><C6B4><EFBFBD>߼<EFBFBD>
if (sourceType == FileSourceType.SMB)
{
// SMBLibrary <20><><EFBFBD>߼<EFBFBD>ͨ<EFBFBD><CDA8><EFBFBD><EFBFBD> Host <20>ֿ<EFBFBD><D6BF><EFBFBD>ShareName <20>ֿ<EFBFBD>
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> IP <20><><EFBFBD><EFBFBD> Path <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFA3AC><EFBFBD><EFBFBD><EFBFBD><EFBFBD>Ҫ<EFBFBD><D2AA> ShareName ƴ<>ں<EFBFBD><DABA><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ֶ<EFBFBD>
// Ϊ<>˷<EFBFBD><CBB7><EFBFBD><E3A3AC><EFBFBD><EFBFBD><EFBFBD><EFBFBD> IP <20><> ShareName ƴ<><C6B4>һ<EFBFBD><D2BB><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Path
// <20><><EFBFBD><EFBFBD>: 192.168.1.5/Music
folder.Path = $"{HostBox.Text}/{rawPath}";
}
else
{
// FTP/WebDAV: 192.168.1.5/pub/music
folder.Path = $"{HostBox.Text}/{rawPath}";
}
}
return folder;
}
public void ShowError(string message)
{
ErrorInfoBar.Message = message;
ErrorInfoBar.IsOpen = true;
}
public void SetProgressBarVisibility(Visibility visibility)
{
ProgressBar.Visibility = visibility;
}
}
}

View File

@@ -0,0 +1,30 @@
using BetterLyrics.WinUI3.Enums;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data;
using System;
using System.Collections.Generic;
using System.Text;
namespace BetterLyrics.WinUI3.Converter
{
public partial class FileSourceTypeToIconConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is FileSourceType type)
{
return type switch
{
FileSourceType.Local => new FontIcon { Glyph = "\uE8B7" }, // Folder
FileSourceType.SMB => new FontIcon { Glyph = "\uE839" }, // Network
FileSourceType.FTP => new FontIcon { Glyph = "\uE838" }, // Globe
FileSourceType.WebDav => new FontIcon { Glyph = "\uE753" }, // Cloud
_ => new FontIcon { Glyph = "\uE8B7" }
};
}
return new FontIcon { Glyph = "\uE8B7" };
}
public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException();
}
}

View File

@@ -11,7 +11,11 @@ namespace BetterLyrics.WinUI3.Converter
{
if (value is string langCode)
{
if (PhoneticHelper.IsPhoneticCode(langCode))
if (langCode == "N/A")
{
return langCode;
}
else if (PhoneticHelper.IsPhoneticCode(langCode))
{
return PhoneticHelper.GetDisplayName(langCode);
}

View File

@@ -1,24 +0,0 @@
using ATL;
using BetterLyrics.WinUI3.Extensions;
using Microsoft.UI.Xaml.Data;
using System;
namespace BetterLyrics.WinUI3.Converter
{
public partial class TrackToLyricsConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is Track track)
{
return track.GetRawLyrics();
}
return "";
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace BetterLyrics.WinUI3.Enums
{
public enum FileSourceType
{
Local,
SMB,
FTP,
WebDav
}
}

View File

@@ -1,33 +0,0 @@
using ATL;
using System.IO;
namespace BetterLyrics.WinUI3.Extensions
{
public static class TrackExtensions
{
extension(Track track)
{
public string GetParentFolderName() => Directory.GetParent(track.Path)?.Name ?? "";
public string GetParentFolderPath() => Directory.GetParent(track.Path)?.FullName ?? "";
public string GetRawLyrics()
{
if (track.Path is string path)
{
try
{
return TagLib.File.Create(path).Tag.Lyrics;
}
catch (System.Exception)
{
return "";
}
}
return "";
}
public string GetFileName() => Path.GetFileName(track.Path);
}
}
}

View File

@@ -107,7 +107,7 @@ namespace BetterLyrics.WinUI3.Helper
public static string? DetectLanguageCode(string? text)
{
if (text == null) return null;
if (string.IsNullOrWhiteSpace(text)) return null;
var guessList = _identifier.Identify(text);
string? code = guessList?.FirstOrDefault()?.Item1.Iso639_2T;
code = code switch

View File

@@ -1,5 +1,6 @@
using BetterLyrics.WinUI3.Hooks;
using DevWinUI;
using Microsoft.UI.Xaml;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
@@ -45,6 +46,12 @@ namespace BetterLyrics.WinUI3.Helper
public static async Task<StorageFile?> PickSaveFileAsync<T>(IDictionary<string, IList<string>> fileTypeChoices)
{
var window = WindowHook.GetWindow<T>();
return await PickSaveFileAsync(window, fileTypeChoices);
}
public static async Task<StorageFile?> PickSaveFileAsync<T>(T? window, IDictionary<string, IList<string>> fileTypeChoices)
{
if (window == null) return null;
var picker = new Windows.Storage.Pickers.FileSavePicker();

View File

@@ -124,8 +124,10 @@ namespace BetterLyrics.WinUI3.Logic
line.AngleTransition.SetEasingType(canvasYScrollTransition.EasingType);
line.AngleTransition.SetDuration(yScrollDuration);
line.AngleTransition.SetDelay(yScrollDelay);
line.AngleTransition.StartTransition(lyricsEffect.IsFanLyricsEnabled ?
Math.PI * (lyricsEffect.FanLyricsAngle / 180.0) * distanceFactor * (i > playingLineIndex ? 1 : -1) : 0);
line.AngleTransition.StartTransition(
(lyricsEffect.IsFanLyricsEnabled && !isMouseScrolling) ?
Math.PI * (lyricsEffect.FanLyricsAngle / 180.0) * distanceFactor * (i > playingLineIndex ? 1 : -1) :
0);
line.YOffsetTransition.SetEasingType(canvasYScrollTransition.EasingType);
line.YOffsetTransition.SetDuration(yScrollDuration);

View File

@@ -213,6 +213,17 @@ namespace BetterLyrics.WinUI3.Logic
if (value >= mousePosition.Y) { result = mid; right = mid - 1; }
else { left = mid + 1; }
}
if (result != -1)
{
var line = lines[result];
double lineTopY = offset + line.TopLeftPosition.Y;
if (mousePosition.Y < lineTopY)
{
result = -1;
}
}
return result;
}

View File

@@ -0,0 +1,39 @@
using BetterLyrics.WinUI3.Models.FileSystem;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace BetterLyrics.WinUI3.Models
{
public class ExtendedTrack : ATL.Track
{
public new string Path { get; private set; } = "";
public string RawLyrics { get; set; } = "";
public string ParentFolderName => Directory.GetParent(Path)?.Name ?? "";
public string ParentFolderPath => Directory.GetParent(Path)?.FullName ?? "";
public string FileName => System.IO.Path.GetFileName(Path);
public ExtendedTrack() : base() { }
public ExtendedTrack(string path) : base(path)
{
Path = path;
}
public ExtendedTrack(string path, Stream stream) : base(stream, System.IO.Path.GetExtension(path))
{
Path = path;
SetRawLyrics(new StreamFileAbstraction(path, stream));
}
private void SetRawLyrics(StreamFileAbstraction streamFileAbstraction)
{
try
{
RawLyrics = TagLib.File.Create(streamFileAbstraction).Tag.Lyrics;
}
catch (Exception) { }
}
}
}

View File

@@ -0,0 +1,55 @@
using FluentFTP;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BetterLyrics.WinUI3.Models.FileSystem
{
public partial class FTPFileSystem : IUnifiedFileSystem
{
private readonly AsyncFtpClient _client;
private readonly string _rootPath; // 服务器上的根路径 (例如 /pub/music)
public FTPFileSystem(string host, string user, string pass, int port, string remotePath)
{
// 如果 path 是 "192.168.1.5/Music",我们需要把 /Music 拆出来
// 但为了简单,假设 host 仅仅是 IPremotePath 才是路径
_rootPath = remotePath ?? "/";
var config = new FtpConfig { ConnectTimeout = 5000 };
_client = new AsyncFtpClient(host, user ?? "anonymous", pass ?? "", port > 0 ? port : 21, config);
}
public async Task<bool> ConnectAsync()
{
await _client.AutoConnect();
return _client.IsConnected;
}
public async Task<List<UnifiedFileItem>> GetFilesAsync(string relativePath)
{
string targetPath = Path.Combine(_rootPath, relativePath).Replace("\\", "/");
var items = await _client.GetListing(targetPath);
return items.Select(i => new UnifiedFileItem
{
Name = i.Name,
FullPath = i.FullName,
IsFolder = i.Type == FtpObjectType.Directory,
Size = i.Size,
LastModified = i.Modified
}).ToList();
}
public async Task<Stream> OpenReadAsync(string fullPath)
{
return await _client.OpenRead(fullPath);
}
public async Task DisconnectAsync() => await _client.Disconnect();
public void Dispose() => _client?.Dispose();
}
}

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Windows.Storage;
namespace BetterLyrics.WinUI3.Models.FileSystem
{
public interface IUnifiedFileSystem : IDisposable
{
Task<bool> ConnectAsync();
Task<List<UnifiedFileItem>> GetFilesAsync(string relativePath);
Task<Stream> OpenReadAsync(string fullPath);
Task DisconnectAsync();
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace BetterLyrics.WinUI3.Models.FileSystem
{
public partial class LocalFileSystem : IUnifiedFileSystem
{
private readonly string _rootPath;
public LocalFileSystem(string rootPath)
{
_rootPath = rootPath;
}
public Task<bool> ConnectAsync()
{
return Task.FromResult(Directory.Exists(_rootPath));
}
public async Task<List<UnifiedFileItem>> GetFilesAsync(string relativePath)
{
var result = new List<UnifiedFileItem>();
var targetPath = string.IsNullOrWhiteSpace(relativePath)
? _rootPath
: Path.Combine(_rootPath, relativePath);
if (!Directory.Exists(targetPath)) return result;
var dirInfo = new DirectoryInfo(targetPath);
foreach (var item in dirInfo.GetFileSystemInfos())
{
bool isDir = (item.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
result.Add(new UnifiedFileItem
{
Name = item.Name,
FullPath = item.FullName,
IsFolder = isDir,
Size = isDir ? 0 : ((FileInfo)item).Length,
LastModified = item.LastWriteTime
});
}
return result;
}
public async Task<Stream> OpenReadAsync(string fullPath)
{
return new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
}
public async Task DisconnectAsync() => await Task.CompletedTask;
public void Dispose() { }
}
}

View File

@@ -0,0 +1,131 @@
using SMBLibrary;
using SMBLibrary.Client;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BetterLyrics.WinUI3.Models.FileSystem
{
public partial class SMBFileSystem : IUnifiedFileSystem
{
private SMB2Client _client;
private ISMBFileStore _fileStore;
private readonly string _ip;
private readonly string _shareName;
private readonly string _pathInsideShare; // 共享里的子路径
private readonly string _username;
private readonly string _password;
// fullPathInput 例如: "192.168.1.5/Music/Pop"
public SMBFileSystem(string fullPathInput, string user, string pass)
{
_username = user;
_password = pass;
// 解析路径:分离 IP 和 共享名
var parts = fullPathInput.Replace("\\", "/").Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 1) _ip = parts[0];
if (parts.Length >= 2) _shareName = parts[1];
// 剩下的部分重新拼起来作为子路径
if (parts.Length > 2)
_pathInsideShare = string.Join("\\", parts.Skip(2));
else
_pathInsideShare = "";
}
public async Task<bool> ConnectAsync()
{
_client = new SMB2Client();
bool connected = _client.Connect(_ip, SMBTransportType.DirectTCPTransport);
if (!connected) return false;
var status = _client.Login(string.Empty, _username, _password);
if (status != NTStatus.STATUS_SUCCESS) return false;
// 连接具体的共享文件夹
if (string.IsNullOrEmpty(_shareName)) return true; // 只连了服务器,没连共享
_fileStore = _client.TreeConnect(_shareName, out status);
return status == NTStatus.STATUS_SUCCESS;
}
public async Task<List<UnifiedFileItem>> GetFilesAsync(string relativePath)
{
var result = new List<UnifiedFileItem>();
if (_fileStore == null) return result;
// 拼接完整路径: Root里面的子路径 + 传入的相对路径
string queryPath = Path.Combine(_pathInsideShare, relativePath).Replace("/", "\\").TrimStart('\\');
// 打开目录
var statusRet = _fileStore.CreateFile(out object handle, out FileStatus status, queryPath,
AccessMask.GENERIC_READ, SMBLibrary.FileAttributes.Directory, ShareAccess.Read,
CreateDisposition.FILE_OPEN, CreateOptions.FILE_DIRECTORY_FILE, null);
if (statusRet != NTStatus.STATUS_SUCCESS) return result;
List<QueryDirectoryFileInformation> fileInfo;
do
{
statusRet = _fileStore.QueryDirectory(out fileInfo, handle, "*", FileInformationClass.FileDirectoryInformation);
List<FileDirectoryInformation> list = fileInfo.Select(x => (FileDirectoryInformation)x).ToList();
foreach (var item in list)
{
// 排除当前目录和父目录
if (item.FileName == "." || item.FileName == "..") continue;
result.Add(new UnifiedFileItem
{
Name = item.FileName,
FullPath = Path.Combine(queryPath, item.FileName),
IsFolder = (item.FileAttributes & SMBLibrary.FileAttributes.Directory) == SMBLibrary.FileAttributes.Directory,
Size = item.AllocationSize,
LastModified = item.LastWriteTime
});
}
if (statusRet == NTStatus.STATUS_NO_MORE_FILES)
{
break;
}
if (statusRet != NTStatus.STATUS_SUCCESS)
{
// Log
break;
}
} while (statusRet == NTStatus.STATUS_SUCCESS);
_fileStore.CloseFile(handle);
return result;
}
public async Task<Stream> OpenReadAsync(string fullPath)
{
var ret = _fileStore.CreateFile(out object handle, out FileStatus status, fullPath,
AccessMask.GENERIC_READ | AccessMask.SYNCHRONIZE, 0, ShareAccess.Read, CreateDisposition.FILE_OPEN, 0, null);
if (ret != NTStatus.STATUS_SUCCESS) throw new IOException($"SMB Open Error: {ret}");
return new SMBReadOnlyStream(_fileStore, handle);
}
public async Task DisconnectAsync()
{
_client?.Disconnect();
await Task.CompletedTask;
}
public void Dispose()
{
_client?.Disconnect();
}
}
}

View File

@@ -0,0 +1,116 @@
using SMBLibrary;
using SMBLibrary.Client;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace BetterLyrics.WinUI3.Models.FileSystem
{
public partial class SMBReadOnlyStream : Stream
{
private readonly ISMBFileStore _store;
private readonly object _handle;
private long _position;
private long _length; // 新增:缓存文件长度
public SMBReadOnlyStream(ISMBFileStore store, object handle)
{
_store = store;
_handle = handle;
_position = 0;
var status = _store.GetFileInformation(out FileInformation result, handle, FileInformationClass.FileStandardInformation);
if (status == NTStatus.STATUS_SUCCESS && result is FileStandardInformation info)
{
_length = info.EndOfFile;
}
else
{
// 如果获取失败,这是一个严重问题,意味着无法 Seek 到末尾
// 暂时设为 0但后续读取可能会出问题
_length = 0;
}
}
public override bool CanRead => true;
public override bool CanSeek => true;
public override bool CanWrite => false;
public override long Length => _length;
public override long Position
{
get => _position;
set => _position = value;
}
public override int Read(byte[] buffer, int offset, int count)
{
// 保护:如果位置已经超过文件末尾,直接返回 0 (EOF)
if (_position >= _length) return 0;
// 保护:防止读取越界 (请求读取量不能超过剩余量)
long remaining = _length - _position;
int bytesToRequest = (int)Math.Min(count, remaining);
// 为了安全,保留对 remaining 的检查是必须的
if (bytesToRequest <= 0) return 0;
var status = _store.ReadFile(out byte[] data, _handle, _position, bytesToRequest);
if (status == NTStatus.STATUS_END_OF_FILE) return 0;
if (status != NTStatus.STATUS_SUCCESS)
{
throw new IOException($"SMB Read failed. Status: {status} (Pos: {_position}, Req: {bytesToRequest})");
}
if (data == null || data.Length == 0) return 0;
Array.Copy(data, 0, buffer, offset, data.Length);
_position += data.Length;
return data.Length;
}
public override long Seek(long offset, SeekOrigin origin)
{
long newPos = _position;
switch (origin)
{
case SeekOrigin.Begin:
newPos = offset;
break;
case SeekOrigin.Current:
newPos = _position + offset;
break;
case SeekOrigin.End:
newPos = _length + offset;
break;
}
// 允许 Seek 超过 EOF (标准 Stream 行为),但在 Read 时会返回 0
if (newPos < 0)
{
throw new IOException("An attempt was made to move the file pointer before the beginning of the file.");
}
_position = newPos;
return _position;
}
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override void Flush() { }
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
try { _store.CloseFile(_handle); } catch { }
}
}
}
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace BetterLyrics.WinUI3.Models.FileSystem
{
public class StreamFileAbstraction : TagLib.File.IFileAbstraction
{
private readonly string _name;
private readonly Stream _stream;
private readonly bool _closeStreamOnDispose;
public StreamFileAbstraction(string path, Stream stream, bool closeStreamOnDispose = false)
{
_name = Path.GetFileName(path);
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
_closeStreamOnDispose = closeStreamOnDispose;
}
public string Name => _name;
public Stream ReadStream => _stream;
public Stream WriteStream
{
get
{
if (_stream.CanWrite)
{
return _stream;
}
throw new InvalidOperationException("The underlying stream is read-only. Tag saving is not supported for this source.");
}
}
public void CloseStream(Stream stream)
{
if (_closeStreamOnDispose)
{
stream?.Dispose();
}
}
}
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace BetterLyrics.WinUI3.Models.FileSystem
{
public class UnifiedFileItem
{
public string Name { get; set; }
public string FullPath { get; set; }
public long Size { get; set; }
public bool IsFolder { get; set; }
public DateTime? LastModified { get; set; }
}
}

View File

@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using WebDav;
namespace BetterLyrics.WinUI3.Models.FileSystem
{
public partial class WebDavFileSystem : IUnifiedFileSystem
{
private readonly WebDavClient _client;
private readonly string _baseUrl;
private readonly string _rootPath;
// host: http://192.168.1.5:5005
// path: /music
public WebDavFileSystem(string host, string user, string pass, int port, string path)
{
if (!host.StartsWith("http")) host = $"http://{host}";
if (port > 0) host = $"{host}:{port}";
_baseUrl = host;
_rootPath = path ?? "/";
_client = new WebDavClient(new WebDavClientParams
{
BaseAddress = new Uri(_baseUrl),
Credentials = new System.Net.NetworkCredential(user, pass)
});
}
public async Task<bool> ConnectAsync()
{
// WebDAV 无状态Propfind 测试根目录连通性
var result = await _client.Propfind(_rootPath);
return result.IsSuccessful;
}
public async Task<List<UnifiedFileItem>> GetFilesAsync(string relativePath)
{
var targetPath = Path.Combine(_rootPath, relativePath).Replace("\\", "/");
var result = await _client.Propfind(targetPath);
var list = new List<UnifiedFileItem>();
if (result.IsSuccessful)
{
foreach (var res in result.Resources)
{
if (res == null || res.Uri == null) continue;
// 排除掉文件夹自身 (WebDAV 通常会把当前请求的文件夹作为第一个结果返回)
// 通过判断 URL 结尾是否一致来简单过滤,或者判断 IsCollection 且 Uri 相同
// 这里简单处理:只要名字不为空
var name = System.Net.WebUtility.UrlDecode(res.Uri.Split('/').LastOrDefault());
if (string.IsNullOrEmpty(name)) continue;
// 如果名字和请求的目录名一样,可能是它自己,跳过 (这需要根据具体服务器响应调整)
// 更稳妥的是比较 Uri
list.Add(new UnifiedFileItem
{
Name = name,
FullPath = res.Uri.ToString(), // WebDAV 需要完整 URI
IsFolder = res.IsCollection,
Size = res.ContentLength ?? 0,
LastModified = res.LastModifiedDate
});
}
}
return list;
}
public async Task<Stream> OpenReadAsync(string fullPath)
{
// WebDAV 获取流
var res = await _client.GetRawFile(fullPath);
if (!res.IsSuccessful) throw new IOException($"WebDAV Error: {res.StatusCode}");
return res.Stream;
}
public async Task DisconnectAsync() => await Task.CompletedTask;
public void Dispose() => _client?.Dispose();
}
}

View File

@@ -1,20 +0,0 @@
// 2025/6/23 by Zhe Fang
using CommunityToolkit.Mvvm.ComponentModel;
namespace BetterLyrics.WinUI3.Models
{
public partial class LocalMediaFolder : ObservableRecipient
{
[ObservableProperty][NotifyPropertyChangedRecipients] public partial bool IsEnabled { get; set; } = true;
[ObservableProperty][NotifyPropertyChangedRecipients] public partial bool IsRealTimeWatchEnabled { get; set; } = false;
[ObservableProperty][NotifyPropertyChangedRecipients] public partial string Path { get; set; }
public LocalMediaFolder() { }
public LocalMediaFolder(string path)
{
Path = path;
}
}
}

View File

@@ -11,7 +11,7 @@ namespace BetterLyrics.WinUI3.Models
{
private static readonly IResourceService _resourceService = Ioc.Default.GetRequiredService<IResourceService>();
public List<LyricsLine> LyricsLines { get; set; }
public List<LyricsLine> LyricsLines { get; set; } = [];
public string? LanguageCode
{
get => field ?? LanguageHelper.DetectLanguageCode(WrappedOriginalText);
@@ -22,7 +22,6 @@ namespace BetterLyrics.WinUI3.Models
public LyricsData()
{
LyricsLines = [];
}
public LyricsData(List<LyricsLine> lyricsLines)
@@ -120,14 +119,18 @@ namespace BetterLyrics.WinUI3.Models
public static LyricsData GetLoadingPlaceholder()
{
return new LyricsData([
new LyricsLine
{
StartMs = 0,
EndMs = (int)TimeSpan.FromMinutes(99).TotalMilliseconds,
OriginalText = "● ● ●",
},
]);
return new LyricsData()
{
LyricsLines = [
new LyricsLine
{
StartMs = 0,
EndMs = (int)TimeSpan.FromMinutes(99).TotalMilliseconds,
OriginalText = "● ● ●",
},
],
LanguageCode = "N/A",
};
}
public LyricsLine? GetLyricsLine(double sec)

View File

@@ -0,0 +1,73 @@
// 2025/6/23 by Zhe Fang
using BetterLyrics.WinUI3.Enums;
using BetterLyrics.WinUI3.Helper;
using BetterLyrics.WinUI3.Models.FileSystem;
using CommunityToolkit.Mvvm.ComponentModel;
using System;
using System.Text.Json.Serialization;
namespace BetterLyrics.WinUI3.Models
{
public partial class MediaFolder : ObservableRecipient
{
[ObservableProperty] public partial string Id { get; set; } = Guid.NewGuid().ToString();
[ObservableProperty][NotifyPropertyChangedRecipients] public partial bool IsEnabled { get; set; } = true;
[ObservableProperty][NotifyPropertyChangedRecipients] public partial bool IsRealTimeWatchEnabled { get; set; } = false;
[ObservableProperty][NotifyPropertyChangedRecipients][NotifyPropertyChangedFor(nameof(ConnectionSummary))] public partial string Path { get; set; }
[ObservableProperty]
[NotifyPropertyChangedRecipients]
[NotifyPropertyChangedFor(nameof(IsLocal))]
[NotifyPropertyChangedFor(nameof(ConnectionSummary))]
public partial FileSourceType SourceType { get; set; } = FileSourceType.Local;
[ObservableProperty][NotifyPropertyChangedRecipients] public partial string Name { get; set; }
[ObservableProperty] public partial string UserName { get; set; }
[ObservableProperty] public partial int Port { get; set; } = -1;
[JsonIgnore] public string Password { get; set; }
[JsonIgnore] public bool IsLocal => SourceType == FileSourceType.Local;
[JsonIgnore]
public string ConnectionSummary
{
get
{
if (IsLocal) return Path;
return $"{SourceType} - {Path} {(string.IsNullOrEmpty(UserName) ? "" : $"({UserName})")}";
}
}
[JsonIgnore] public string VaultKey => $"{Id}-{UserName}";
public MediaFolder() { }
public MediaFolder(string path)
{
Path = path;
}
public IUnifiedFileSystem? CreateFileSystem()
{
if (!IsEnabled) return null;
if (string.IsNullOrEmpty(Password) && !IsLocal)
{
Password = PasswordVaultHelper.Get(Constants.App.AppName, VaultKey) ?? "";
}
return SourceType switch
{
FileSourceType.Local => new LocalFileSystem(Path),
FileSourceType.SMB => new SMBFileSystem(Path, UserName, Password),
FileSourceType.FTP => new FTPFileSystem(Path, UserName, Password, Port, Path),
FileSourceType.WebDav => new WebDavFileSystem(Path, UserName, Password, Port, Path),
_ => throw new NotImplementedException()
};
}
}
}

View File

@@ -4,9 +4,9 @@ namespace BetterLyrics.WinUI3.Models
{
public class PlayQueueItem
{
public Track Track { get; set; }
public ExtendedTrack Track { get; set; }
public PlayQueueItem(Track track)
public PlayQueueItem(ExtendedTrack track)
{
Track = track;
}

View File

@@ -12,7 +12,7 @@ namespace BetterLyrics.WinUI3.Models.Settings
[ObservableProperty][NotifyPropertyChangedRecipients] public partial MusicGallerySettings MusicGallerySettings { get; set; } = new MusicGallerySettings();
[ObservableProperty][NotifyPropertyChangedRecipients] public partial AdvancedSettings AdvancedSettings { get; set; } = new AdvancedSettings();
[ObservableProperty][NotifyPropertyChangedRecipients] public partial FullyObservableCollection<LocalMediaFolder> LocalMediaFolders { get; set; } = [];
[ObservableProperty][NotifyPropertyChangedRecipients] public partial FullyObservableCollection<MediaFolder> LocalMediaFolders { get; set; } = [];
[ObservableProperty][NotifyPropertyChangedRecipients] public partial FullyObservableCollection<MediaSourceProviderInfo> MediaSourceProvidersInfo { get; set; } = [];
[ObservableProperty][NotifyPropertyChangedRecipients] public partial FullyObservableCollection<MappedSongSearchQuery> MappedSongSearchQueries { get; set; } = [];
[ObservableProperty][NotifyPropertyChangedRecipients] public partial FullyObservableCollection<LyricsWindowStatus> WindowBoundsRecords { get; set; } = [];

View File

@@ -233,13 +233,15 @@ namespace BetterLyrics.WinUI3.Renderer
catch (Exception) { }
}
public void CalculateLyrics3DMatrix(LyricsEffectSettings lyricsEffect, double lyricsX, double lyricsY, double lyricsWidth, double canvasHeight)
public void CalculateLyrics3DMatrix(LyricsStyleSettings lyricsStyle, LyricsEffectSettings lyricsEffect, double lyricsX, double lyricsY, double lyricsWidth, double lyricsHeight)
{
if (!lyricsEffect.Is3DLyricsEnabled) return;
var playingLineTopOffsetFactor = lyricsStyle.PlayingLineTopOffset / 100.0;
Vector3 center = new(
(float)(lyricsX + lyricsWidth / 2),
(float)(lyricsY + canvasHeight / 2),
(float)(lyricsY + lyricsHeight * playingLineTopOffsetFactor / 2),
0);
float rotationX = (float)(Math.PI * lyricsEffect.Lyrics3DXAngle / 180.0);

View File

@@ -63,7 +63,7 @@ namespace BetterLyrics.WinUI3.Renderer
float blur,
float opacity)
{
if (opacity <= 0) return;
if (float.IsNaN(opacity) || opacity <= 0) return;
var bounds = layout.LayoutBounds;
var destRect = new Rect(

View File

@@ -49,7 +49,7 @@ namespace BetterLyrics.WinUI3.Services.AlbumArtSearchService
switch (provider.Provider)
{
case AlbumArtSearchProvider.Local:
result = SearchFile(songInfo)?.AsBuffer();
result = (await SearchFile(songInfo))?.AsBuffer();
break;
case AlbumArtSearchProvider.SMTC:
result = bufferFromSMTC;
@@ -77,29 +77,73 @@ namespace BetterLyrics.WinUI3.Services.AlbumArtSearchService
return null;
}
private byte[]? SearchFile(SongInfo songInfo)
private async Task<byte[]?> SearchFile(SongInfo songInfo)
{
foreach (var folder in _settingsService.AppSettings.LocalMediaFolders)
{
if (Directory.Exists(folder.Path) && folder.IsEnabled)
if (!folder.IsEnabled) continue;
try
{
foreach (var file in DirectoryHelper.GetAllFiles(folder.Path))
using var fs = folder.CreateFileSystem();
if (fs == null) continue;
if (!await fs.ConnectAsync()) continue;
// 递归扫描
var foldersToScan = new Queue<string>();
foldersToScan.Enqueue(""); // 根目录
while (foldersToScan.Count > 0)
{
if (FileHelper.MusicExtensions.Contains(Path.GetExtension(file)))
var currentPath = foldersToScan.Dequeue();
var items = await fs.GetFilesAsync(currentPath);
foreach (var item in items)
{
Track track = new(file);
if ((track.Title == songInfo.Title && track.Artist == songInfo.DisplayArtists) || StringHelper.IsSwitchableNormalizedMatch(Path.GetFileNameWithoutExtension(file), songInfo.DisplayArtists, songInfo.Title))
if (item.IsFolder)
{
var bytes = track.EmbeddedPictures.FirstOrDefault()?.PictureData;
if (bytes != null)
foldersToScan.Enqueue(Path.Combine(currentPath, item.Name));
continue;
}
var ext = Path.GetExtension(item.Name).ToLower();
if (FileHelper.MusicExtensions.Contains(ext))
{
try
{
using (var stream = await fs.OpenReadAsync(item.FullPath))
{
var track = new ExtendedTrack(item.FullPath, stream);
bool isMetadataMatch = (track.Title == songInfo.Title && track.Artist == songInfo.DisplayArtists);
bool isFilenameMatch = StringHelper.IsSwitchableNormalizedMatch(
Path.GetFileNameWithoutExtension(item.Name),
songInfo.DisplayArtists,
songInfo.Title
);
if (isMetadataMatch || isFilenameMatch)
{
var bytes = track.EmbeddedPictures.FirstOrDefault()?.PictureData;
if (bytes != null && bytes.Length > 0)
{
return bytes;
}
}
}
}
catch
{
return bytes;
}
}
}
}
}
catch
{
}
}
return null;
}

View File

@@ -242,7 +242,7 @@ namespace BetterLyrics.WinUI3.Services.LyricsSearchService
lyricsSearchResult = await SearchAmllTtmlDbAsync(songInfo);
break;
case LyricsSearchProvider.LocalMusicFile:
lyricsSearchResult = SearchEmbedded(songInfo);
lyricsSearchResult = await SearchEmbedded(songInfo);
break;
case LyricsSearchProvider.LocalLrcFile:
case LyricsSearchProvider.LocalEslrcFile:
@@ -277,7 +277,9 @@ namespace BetterLyrics.WinUI3.Services.LyricsSearchService
private async Task<LyricsSearchResult> SearchFile(SongInfo songInfo, LyricsFormat format)
{
int maxScore = 0;
string? bestFile = null;
MediaFolder? bestFolder = null;
string? bestFilePath = null;
var lyricsSearchResult = new LyricsSearchResult();
@@ -288,47 +290,97 @@ namespace BetterLyrics.WinUI3.Services.LyricsSearchService
foreach (var folder in _settingsService.AppSettings.LocalMediaFolders)
{
if (Directory.Exists(folder.Path) && folder.IsEnabled)
if (!folder.IsEnabled) continue;
try
{
try
using var fs = folder.CreateFileSystem();
if (fs == null) continue;
if (!await fs.ConnectAsync()) continue;
// 递归扫描
var foldersToScan = new Queue<string>();
foldersToScan.Enqueue(""); // 从根目录开始
string targetExt = format.ToFileExtension();
while (foldersToScan.Count > 0)
{
foreach (var file in DirectoryHelper.GetAllFiles(folder.Path, $"*{format.ToFileExtension()}"))
var currentPath = foldersToScan.Dequeue();
var items = await fs.GetFilesAsync(currentPath);
foreach (var item in items)
{
int score = MetadataComparer.CalculateScore(songInfo, new LyricsSearchResult { Reference = file });
if (score > maxScore)
if (item.IsFolder)
{
bestFile = file;
maxScore = score;
foldersToScan.Enqueue(Path.Combine(currentPath, item.Name));
continue;
}
if (item.Name.EndsWith(targetExt, StringComparison.OrdinalIgnoreCase))
{
int score = MetadataComparer.CalculateScore(songInfo, new LyricsSearchResult { Reference = item.FullPath });
if (score > maxScore)
{
maxScore = score;
bestFilePath = item.FullPath;
bestFolder = folder;
}
}
}
}
catch (Exception)
{
}
}
catch (Exception ex)
{
// 日志记录...
}
}
if (bestFile != null)
// 4. 如果找到了最佳匹配,读取内容
if (bestFolder != null && bestFilePath != null)
{
lyricsSearchResult.Reference = bestFile;
lyricsSearchResult.MatchPercentage = maxScore;
string? raw = await File.ReadAllTextAsync(bestFile, FileHelper.GetEncoding(bestFile));
if (raw != null)
try
{
lyricsSearchResult.Raw = raw;
// 重新连接以读取文件 (因为之前的 fs 已经在 using 结束时释放)
using var fs = bestFolder.CreateFileSystem();
if (fs != null && await fs.ConnectAsync())
{
using var stream = await fs.OpenReadAsync(bestFilePath);
// 使用 StreamReader 读取文本
// 注意:这里简单使用 Default 编码,如果需要探测编码(FileHelper.GetEncoding)
// 可能需要先读一部分字节来判断,或者使用带编码探测的库。
using var reader = new StreamReader(stream);
string raw = await reader.ReadToEndAsync();
lyricsSearchResult.Reference = bestFilePath;
lyricsSearchResult.MatchPercentage = maxScore;
lyricsSearchResult.Raw = raw;
}
}
catch (Exception)
{
// 读取失败处理
}
}
return lyricsSearchResult;
}
private LyricsSearchResult SearchEmbedded(SongInfo songInfo)
private async Task<LyricsSearchResult> SearchEmbedded(SongInfo songInfo)
{
int bestScore = 0;
string? bestFile = null;
string? bestFilePath = null;
string? bestRaw = null;
// 用于最后回填 Metadata
string? bestTitle = null;
string[]? bestArtists = null;
string? bestAlbum = null;
double bestDuration = 0;
var lyricsSearchResult = new LyricsSearchResult
{
Provider = LyricsSearchProvider.LocalMusicFile,
@@ -336,49 +388,89 @@ namespace BetterLyrics.WinUI3.Services.LyricsSearchService
foreach (var folder in _settingsService.AppSettings.LocalMediaFolders)
{
if (Directory.Exists(folder.Path) && folder.IsEnabled)
if (!folder.IsEnabled) continue;
try
{
foreach (var file in DirectoryHelper.GetAllFiles(folder.Path))
using var fs = folder.CreateFileSystem();
if (fs == null) continue;
if (!await fs.ConnectAsync()) continue;
var foldersToScan = new Queue<string>();
foldersToScan.Enqueue("");
while (foldersToScan.Count > 0)
{
if (FileHelper.MusicExtensions.Contains(Path.GetExtension(file)))
var currentPath = foldersToScan.Dequeue();
var items = await fs.GetFilesAsync(currentPath);
foreach (var item in items)
{
var track = new Track(file);
var raw = track.GetRawLyrics();
if (!string.IsNullOrEmpty(raw))
if (item.IsFolder)
{
int score = MetadataComparer.CalculateScore(songInfo, new LyricsSearchResult
{
Title = track.Title,
Artists = track.Artist.Split(ATL.Settings.DisplayValueSeparator),
Album = track.Album,
Duration = track.Duration,
Reference = file,
});
foldersToScan.Enqueue(Path.Combine(currentPath, item.Name));
continue;
}
if (score > bestScore)
var ext = Path.GetExtension(item.Name).ToLower();
if (FileHelper.MusicExtensions.Contains(ext))
{
try
{
bestScore = score;
bestFile = file;
bestRaw = raw;
using var stream = await fs.OpenReadAsync(item.FullPath);
var track = new ExtendedTrack(item.FullPath, stream);
var raw = track.RawLyrics;
if (!string.IsNullOrEmpty(raw))
{
int score = MetadataComparer.CalculateScore(songInfo, new LyricsSearchResult
{
Title = track.Title,
Artists = track.Artist?.Split(ATL.Settings.DisplayValueSeparator),
Album = track.Album,
Duration = track.Duration,
Reference = item.FullPath,
});
if (score > bestScore)
{
bestScore = score;
bestFilePath = item.FullPath;
bestRaw = raw;
// 缓存当前最佳的元数据,避免最后还需要重新打开文件读一次
bestTitle = track.Title;
bestArtists = track.Artist?.Split(ATL.Settings.DisplayValueSeparator);
bestAlbum = track.Album;
bestDuration = track.Duration;
}
}
}
catch
{
// 单个文件解析失败忽略
}
}
}
}
}
catch
{
// 文件夹扫描失败忽略
}
}
if (bestFile != null)
if (bestFilePath != null)
{
var track = new Track(bestFile);
lyricsSearchResult.Title = track.Title;
lyricsSearchResult.Artists = track.Artist.Split(ATL.Settings.DisplayValueSeparator);
lyricsSearchResult.Album = track.Album;
lyricsSearchResult.Duration = track.Duration;
// 直接使用缓存的数据,不需要 new Track(bestFile) 了
lyricsSearchResult.Title = bestTitle;
lyricsSearchResult.Artists = bestArtists;
lyricsSearchResult.Album = bestAlbum;
lyricsSearchResult.Duration = bestDuration;
lyricsSearchResult.Raw = bestRaw;
lyricsSearchResult.Reference = bestFile;
lyricsSearchResult.Reference = bestFilePath;
lyricsSearchResult.MatchPercentage = bestScore;
}
@@ -560,13 +652,14 @@ namespace BetterLyrics.WinUI3.Services.LyricsSearchService
}
ISearchResult? result;
if (searcher == Searchers.Netease && songInfo.SongId != null)
if (songInfo.SongId != null && searcher == Searchers.Netease && PlayerIDHelper.IsNeteaseFamily(songInfo.PlayerId))
{
result = new NeteaseSearchResult("", [], "", [], 0, songInfo.SongId);
result = new NeteaseSearchResult(songInfo.Title, songInfo.Artists, songInfo.Album, [], (int)songInfo.DurationMs, songInfo.SongId);
}
else if (searcher == Searchers.QQMusic && songInfo.SongId != null)
else if (songInfo.SongId != null && searcher == Searchers.QQMusic && songInfo.PlayerId == Constants.PlayerID.QQMusic)
{
result = new QQMusicSearchResult("", [], "", [], 0, songInfo.SongId, "");
result = new QQMusicSearchResult(songInfo.Title, songInfo.Artists, songInfo.Album, [], (int)songInfo.DurationMs, songInfo.SongId, "");
}
else
{

View File

@@ -63,7 +63,7 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
[ObservableProperty][NotifyPropertyChangedRecipients] public partial bool CurrentIsPlaying { get; private set; } = false;
[ObservableProperty][NotifyPropertyChangedRecipients] public partial TimeSpan CurrentPosition { get; private set; } = TimeSpan.Zero;
[ObservableProperty][NotifyPropertyChangedRecipients] public partial SongInfo? CurrentSongInfo { get; private set; }
[ObservableProperty][NotifyPropertyChangedRecipients] public partial SongInfo? CurrentSongInfo { get; private set; } = SongInfoExtensions.Placeholder;
[ObservableProperty] public partial MediaSourceProviderInfo? CurrentMediaSourceProviderInfo { get; set; }
@@ -331,9 +331,9 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
CurrentSongInfo = new SongInfo
{
Title = mediaProperties?.Title ?? "",
Artists = fixedArtist?.SplitByCommonSplitter() ?? [],
Album = fixedAlbum ?? "",
Title = mediaProperties?.Title ?? "N/A",
Artists = fixedArtist?.SplitByCommonSplitter() ?? ["N/A"],
Album = fixedAlbum ?? "N/A",
DurationMs = mediaSession?.ControlSession?.GetTimelineProperties().EndTime.TotalMilliseconds ?? 0,
PlayerId = sessionId,
SongId = songId,

View File

@@ -120,6 +120,9 @@
<data name="ActionCompleted" xml:space="preserve">
<value>Operation completed</value>
</data>
<data name="Add" xml:space="preserve">
<value>Add</value>
</data>
<data name="AlbumArtSearchLocalProvider" xml:space="preserve">
<value>Local music files</value>
</data>
@@ -546,6 +549,24 @@
<data name="PrivacyPolicy.Content" xml:space="preserve">
<value>Privacy policy</value>
</data>
<data name="RemoteServerConfigControlPassword.Header" xml:space="preserve">
<value>Password</value>
</data>
<data name="RemoteServerConfigControlPath.Header" xml:space="preserve">
<value>Path</value>
</data>
<data name="RemoteServerConfigControlPort.Header" xml:space="preserve">
<value>Port</value>
</data>
<data name="RemoteServerConfigControlServerAddress.Header" xml:space="preserve">
<value>Server Address</value>
</data>
<data name="RemoteServerConfigControlServerAddressRequired" xml:space="preserve">
<value>Server address is required</value>
</data>
<data name="RemoteServerConfigControlUsername.Header" xml:space="preserve">
<value>Username</value>
</data>
<data name="Romaji" xml:space="preserve">
<value>Romaji</value>
</data>
@@ -594,9 +615,6 @@
<data name="SettingsPageAdaptEnvColor.Header" xml:space="preserve">
<value>Adapt to environmental color</value>
</data>
<data name="SettingsPageAddFolder.Header" xml:space="preserve">
<value>Add a folder</value>
</data>
<data name="SettingsPageAddFolderButton.Content" xml:space="preserve">
<value>Add</value>
</data>
@@ -1044,6 +1062,9 @@
<data name="SettingsPageListenNewSession.Header" xml:space="preserve">
<value>Enable monitoring for new playback sources</value>
</data>
<data name="SettingsPageLocalFolder.Text" xml:space="preserve">
<value>Local Folder</value>
</data>
<data name="SettingsPageLog.Header" xml:space="preserve">
<value>Log record</value>
</data>

View File

@@ -120,6 +120,9 @@
<data name="ActionCompleted" xml:space="preserve">
<value>操作が完了しました</value>
</data>
<data name="Add" xml:space="preserve">
<value>追加</value>
</data>
<data name="AlbumArtSearchLocalProvider" xml:space="preserve">
<value>ローカル音楽ファイル</value>
</data>
@@ -546,6 +549,24 @@
<data name="PrivacyPolicy.Content" xml:space="preserve">
<value>プライバシーポリシー</value>
</data>
<data name="RemoteServerConfigControlPassword.Header" xml:space="preserve">
<value>パスワード</value>
</data>
<data name="RemoteServerConfigControlPath.Header" xml:space="preserve">
<value>パス</value>
</data>
<data name="RemoteServerConfigControlPort.Header" xml:space="preserve">
<value>ポート</value>
</data>
<data name="RemoteServerConfigControlServerAddress.Header" xml:space="preserve">
<value>サーバーアドレス</value>
</data>
<data name="RemoteServerConfigControlServerAddressRequired" xml:space="preserve">
<value>住所は必須です</value>
</data>
<data name="RemoteServerConfigControlUsername.Header" xml:space="preserve">
<value>ユーザー名</value>
</data>
<data name="Romaji" xml:space="preserve">
<value>ローマン</value>
</data>
@@ -594,9 +615,6 @@
<data name="SettingsPageAdaptEnvColor.Header" xml:space="preserve">
<value>環境の色に適応しましょう</value>
</data>
<data name="SettingsPageAddFolder.Header" xml:space="preserve">
<value>フォルダーを追加します</value>
</data>
<data name="SettingsPageAddFolderButton.Content" xml:space="preserve">
<value>追加</value>
</data>
@@ -1044,6 +1062,9 @@
<data name="SettingsPageListenNewSession.Header" xml:space="preserve">
<value>新しい再生ソースの監視を有効にします</value>
</data>
<data name="SettingsPageLocalFolder.Text" xml:space="preserve">
<value>ローカル フォルダ</value>
</data>
<data name="SettingsPageLog.Header" xml:space="preserve">
<value>ログレコード</value>
</data>

View File

@@ -120,6 +120,9 @@
<data name="ActionCompleted" xml:space="preserve">
<value>작전 완료</value>
</data>
<data name="Add" xml:space="preserve">
<value>추가하다</value>
</data>
<data name="AlbumArtSearchLocalProvider" xml:space="preserve">
<value>로컬 음악 파일</value>
</data>
@@ -546,6 +549,24 @@
<data name="PrivacyPolicy.Content" xml:space="preserve">
<value>개인정보 보호정책</value>
</data>
<data name="RemoteServerConfigControlPassword.Header" xml:space="preserve">
<value>비밀번호</value>
</data>
<data name="RemoteServerConfigControlPath.Header" xml:space="preserve">
<value>길</value>
</data>
<data name="RemoteServerConfigControlPort.Header" xml:space="preserve">
<value>항구</value>
</data>
<data name="RemoteServerConfigControlServerAddress.Header" xml:space="preserve">
<value>IP 주소</value>
</data>
<data name="RemoteServerConfigControlServerAddressRequired" xml:space="preserve">
<value>서버 주소가 필요합니다</value>
</data>
<data name="RemoteServerConfigControlUsername.Header" xml:space="preserve">
<value>사용자 이름</value>
</data>
<data name="Romaji" xml:space="preserve">
<value>로만</value>
</data>
@@ -594,9 +615,6 @@
<data name="SettingsPageAdaptEnvColor.Header" xml:space="preserve">
<value>환경 색상에 적응하세요</value>
</data>
<data name="SettingsPageAddFolder.Header" xml:space="preserve">
<value>폴더를 추가하십시오</value>
</data>
<data name="SettingsPageAddFolderButton.Content" xml:space="preserve">
<value>추가하다</value>
</data>
@@ -1044,6 +1062,9 @@
<data name="SettingsPageListenNewSession.Header" xml:space="preserve">
<value>새로운 재생 소스에 대한 모니터링을 활성화하십시오</value>
</data>
<data name="SettingsPageLocalFolder.Text" xml:space="preserve">
<value>로컬 폴더</value>
</data>
<data name="SettingsPageLog.Header" xml:space="preserve">
<value>로그 레코드</value>
</data>

View File

@@ -120,6 +120,9 @@
<data name="ActionCompleted" xml:space="preserve">
<value>操作完成</value>
</data>
<data name="Add" xml:space="preserve">
<value>添加</value>
</data>
<data name="AlbumArtSearchLocalProvider" xml:space="preserve">
<value>本地音乐文件</value>
</data>
@@ -546,6 +549,24 @@
<data name="PrivacyPolicy.Content" xml:space="preserve">
<value>隐私政策</value>
</data>
<data name="RemoteServerConfigControlPassword.Header" xml:space="preserve">
<value>密码</value>
</data>
<data name="RemoteServerConfigControlPath.Header" xml:space="preserve">
<value>路径</value>
</data>
<data name="RemoteServerConfigControlPort.Header" xml:space="preserve">
<value>端口</value>
</data>
<data name="RemoteServerConfigControlServerAddress.Header" xml:space="preserve">
<value>服务器地址</value>
</data>
<data name="RemoteServerConfigControlServerAddressRequired" xml:space="preserve">
<value>服务器地址为必填项</value>
</data>
<data name="RemoteServerConfigControlUsername.Header" xml:space="preserve">
<value>用户名</value>
</data>
<data name="Romaji" xml:space="preserve">
<value>罗马音</value>
</data>
@@ -594,9 +615,6 @@
<data name="SettingsPageAdaptEnvColor.Header" xml:space="preserve">
<value>适应环境色彩</value>
</data>
<data name="SettingsPageAddFolder.Header" xml:space="preserve">
<value>添加文件夹</value>
</data>
<data name="SettingsPageAddFolderButton.Content" xml:space="preserve">
<value>添加</value>
</data>
@@ -1044,6 +1062,9 @@
<data name="SettingsPageListenNewSession.Header" xml:space="preserve">
<value>启用对新播放源的监听</value>
</data>
<data name="SettingsPageLocalFolder.Text" xml:space="preserve">
<value>本地文件夹</value>
</data>
<data name="SettingsPageLog.Header" xml:space="preserve">
<value>日志记录</value>
</data>

View File

@@ -120,6 +120,9 @@
<data name="ActionCompleted" xml:space="preserve">
<value>操作完成</value>
</data>
<data name="Add" xml:space="preserve">
<value>添加</value>
</data>
<data name="AlbumArtSearchLocalProvider" xml:space="preserve">
<value>本地音樂文件</value>
</data>
@@ -546,6 +549,24 @@
<data name="PrivacyPolicy.Content" xml:space="preserve">
<value>隱私政策</value>
</data>
<data name="RemoteServerConfigControlPassword.Header" xml:space="preserve">
<value>密碼</value>
</data>
<data name="RemoteServerConfigControlPath.Header" xml:space="preserve">
<value>路徑</value>
</data>
<data name="RemoteServerConfigControlPort.Header" xml:space="preserve">
<value>連接埠</value>
</data>
<data name="RemoteServerConfigControlServerAddress.Header" xml:space="preserve">
<value>伺服器位址</value>
</data>
<data name="RemoteServerConfigControlServerAddressRequired" xml:space="preserve">
<value>伺服器位址為必填</value>
</data>
<data name="RemoteServerConfigControlUsername.Header" xml:space="preserve">
<value>使用者名稱</value>
</data>
<data name="Romaji" xml:space="preserve">
<value>羅馬音</value>
</data>
@@ -594,9 +615,6 @@
<data name="SettingsPageAdaptEnvColor.Header" xml:space="preserve">
<value>適應環境色彩</value>
</data>
<data name="SettingsPageAddFolder.Header" xml:space="preserve">
<value>新增資料夾</value>
</data>
<data name="SettingsPageAddFolderButton.Content" xml:space="preserve">
<value>添加</value>
</data>
@@ -1044,6 +1062,9 @@
<data name="SettingsPageListenNewSession.Header" xml:space="preserve">
<value>啟用對新播放來源的監聽</value>
</data>
<data name="SettingsPageLocalFolder.Text" xml:space="preserve">
<value>本地資料夾</value>
</data>
<data name="SettingsPageLog.Header" xml:space="preserve">
<value>日誌記錄</value>
</data>

View File

@@ -37,9 +37,6 @@ namespace BetterLyrics.WinUI3.ViewModels
[ObservableProperty]
public partial ObservableCollection<LyricsData>? LyricsDataArr { get; set; }
[ObservableProperty]
public partial LyricsLine? SelectedLyricsLine { get; set; }
[ObservableProperty]
public partial MappedSongSearchQuery? MappedSongSearchQuery { get; set; }
@@ -99,6 +96,15 @@ namespace BetterLyrics.WinUI3.ViewModels
return found;
}
public void PlayLyricsLine(LyricsLine? value)
{
if (value?.StartMs == null)
{
return;
}
_mediaSessionsService.ChangePosition(value.StartMs / 1000.0);
}
[RelayCommand]
private void Search()
{
@@ -188,15 +194,6 @@ namespace BetterLyrics.WinUI3.ViewModels
}
}
partial void OnSelectedLyricsLineChanged(LyricsLine? value)
{
if (value?.StartMs == null)
{
return;
}
_mediaSessionsService.ChangePosition(value.StartMs / 1000.0);
}
public void Receive(PropertyChangedMessage<SongInfo?> message)
{
if (message.Sender is IMediaSessionsService)

View File

@@ -1,4 +1,7 @@
using BetterLyrics.WinUI3.Helper;
using BetterLyrics.WinUI3.Controls;
using BetterLyrics.WinUI3.Enums;
using BetterLyrics.WinUI3.Helper;
using BetterLyrics.WinUI3.Hooks;
using BetterLyrics.WinUI3.Models;
using BetterLyrics.WinUI3.Models.Settings;
using BetterLyrics.WinUI3.Services.ResourceService;
@@ -12,6 +15,7 @@ using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Windows.Foundation;
namespace BetterLyrics.WinUI3.ViewModels
{
@@ -30,17 +34,6 @@ namespace BetterLyrics.WinUI3.ViewModels
AppSettings = _settingsService.AppSettings;
}
[RelayCommand]
private async Task SelectAndAddFolderAsync(UIElement sender)
{
var folder = await PickerHelper.PickSingleFolderAsync<SettingsWindow>();
if (folder != null)
{
AddFolderAsync(folder.Path);
}
}
private void AddFolderAsync(string path)
{
var normalizedPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
@@ -62,13 +55,93 @@ namespace BetterLyrics.WinUI3.ViewModels
}
else
{
AppSettings.LocalMediaFolders.Add(new LocalMediaFolder(path));
AppSettings.LocalMediaFolders.Add(new MediaFolder(path));
}
}
public void RemoveFolderAsync(LocalMediaFolder folder)
public void RemoveFolderAsync(MediaFolder folder)
{
AppSettings.LocalMediaFolders.Remove(folder);
}
[RelayCommand]
private async Task SelectAndAddFolderAsync(UIElement sender)
{
var folder = await PickerHelper.PickSingleFolderAsync<SettingsWindow>();
if (folder != null)
{
AddFolderAsync(folder.Path);
}
}
[RelayCommand]
private async Task AddRemoteSourceAsync(string protocolType)
{
var dialog = new ContentDialog
{
XamlRoot = WindowHook.GetWindow<SettingsWindow>()?.Content.XamlRoot,
Style = Application.Current.Resources["DefaultContentDialogStyle"] as Style,
Title = protocolType,
PrimaryButtonText = _resourceService.GetLocalizedString("Add"),
CloseButtonText = _resourceService.GetLocalizedString("Cancel"),
DefaultButton = ContentDialogButton.Primary,
Content = new RemoteServerConfigControl(protocolType)
};
dialog.PrimaryButtonClick += async (s, e) =>
{
var configControl = (RemoteServerConfigControl)dialog.Content;
try
{
e.Cancel = true;
dialog.IsPrimaryButtonEnabled = false;
configControl.IsEnabled = false;
configControl.SetProgressBarVisibility(Visibility.Visible);
var tempFolder = configControl.GetConfig();
var provider = tempFolder.CreateFileSystem();
bool isConnected = provider != null && await provider.ConnectAsync();
if (isConnected)
{
await provider!.DisconnectAsync();
PasswordVaultHelper.Save(Constants.App.AppName, tempFolder.VaultKey, tempFolder.Password);
AppSettings.LocalMediaFolders.Add(tempFolder);
e.Cancel = false;
}
else
{
ShowErrorTip(configControl, _resourceService.GetLocalizedString("SettingsPageServerTestFailedInfo"));
}
}
catch (Exception ex)
{
ShowErrorTip(configControl, ex.Message);
}
finally
{
dialog.IsPrimaryButtonEnabled = true;
configControl.IsEnabled = true;
configControl.SetProgressBarVisibility(Visibility.Collapsed);
}
};
await dialog.ShowAsync();
}
private void ShowErrorTip(RemoteServerConfigControl control, string message)
{
// 你可以在 RemoteServerConfigControl 里加一个 InfoBar 用来显示错误
// 假设你在 UserControl 里公开了一个 ShowError 方法
control.ShowError(message);
}
}
}

View File

@@ -43,11 +43,11 @@ namespace BetterLyrics.WinUI3.ViewModels
private readonly DispatcherQueueTimer _refreshSongsTimer;
// All songs
private List<Track> _tracks = [];
private List<ExtendedTrack> _tracks = [];
// Songs in current playlist
private List<Track> _playlistTracks = [];
private List<ExtendedTrack> _playlistTracks = [];
// Filtered songs based on search query for current playlist
private List<Track> _filteredTracks = [];
private List<ExtendedTrack> _filteredTracks = [];
[ObservableProperty]
public partial AppSettings AppSettings { get; set; }
@@ -62,7 +62,7 @@ namespace BetterLyrics.WinUI3.ViewModels
public partial ObservableCollection<GroupInfoList> GroupedTracks { get; set; } = [];
[ObservableProperty]
public partial List<Track> SelectedTracks { get; set; } = [];
public partial List<ExtendedTrack> SelectedTracks { get; set; } = [];
[ObservableProperty]
public partial int SelectedTracksTotalDuration { get; set; } = 0;
@@ -73,7 +73,7 @@ namespace BetterLyrics.WinUI3.ViewModels
public PlayQueueItem? PlayingQueueItem => TrackPlayingQueue.ElementAtOrDefault(AppSettings.MusicGallerySettings.PlayQueueIndex);
[ObservableProperty]
public partial Track? PlayingTrack { get; set; } = null;
public partial ExtendedTrack? PlayingTrack { get; set; } = null;
[ObservableProperty]
public partial CommonSongProperty SongOrderType { get; set; } = CommonSongProperty.Title;
@@ -90,7 +90,7 @@ namespace BetterLyrics.WinUI3.ViewModels
public partial bool IsDataLoading { get; set; } = false;
[ObservableProperty]
public partial Track TrackRightTapped { get; set; } = new();
public partial ExtendedTrack TrackRightTapped { get; set; } = new();
[ObservableProperty]
public partial string SongSearchQuery { get; set; } = string.Empty;
@@ -103,7 +103,7 @@ namespace BetterLyrics.WinUI3.ViewModels
_resourceService = resourceService;
AppSettings = _settingsService.AppSettings;
TrackPlayingQueue = [.. AppSettings.MusicGallerySettings.PlayQueuePaths.Select(x => new PlayQueueItem(new Track(x)))];
TrackPlayingQueue = [.. AppSettings.MusicGallerySettings.PlayQueuePaths.Select(x => new PlayQueueItem(new ExtendedTrack(x)))];
TrackPlayingQueue.CollectionChanged += TrackPlayingQueue_CollectionChanged;
SongsTabInfoList.Add(new SongsTabInfo(_resourceService.GetLocalizedString("MusicGalleryPageAllSongs"), "\uE8A9", false, false, CommonSongProperty.Title, string.Empty));
@@ -281,28 +281,75 @@ namespace BetterLyrics.WinUI3.ViewModels
IsDataLoading = true;
_tracks.Clear();
Task.Run(() =>
Task.Run(async () =>
{
try
{
foreach (var folder in _settingsService.AppSettings.LocalMediaFolders)
{
if (Directory.Exists(folder.Path) && folder.IsEnabled)
if (!folder.IsEnabled) continue;
try
{
foreach (var file in DirectoryHelper.GetAllFiles(folder.Path))
using var fs = folder.CreateFileSystem();
if (fs == null) continue;
if (!await fs.ConnectAsync()) continue;
// 递归扫描队列
var foldersToScan = new Queue<string>();
foldersToScan.Enqueue(""); // 从根目录开始
while (foldersToScan.Count > 0)
{
if (FileHelper.MusicExtensions.Contains(Path.GetExtension(file)))
var currentPath = foldersToScan.Dequeue();
var items = await fs.GetFilesAsync(currentPath);
foreach (var item in items)
{
Track track = new(file);
if (track.Duration <= 0) continue;
_tracks.Add(track);
if (item.IsFolder)
{
foldersToScan.Enqueue(Path.Combine(currentPath, item.Name));
continue;
}
var ext = Path.GetExtension(item.Name).ToLower();
if (FileHelper.MusicExtensions.Contains(ext))
{
try
{
using (var stream = await fs.OpenReadAsync(item.FullPath))
{
ExtendedTrack track = new ExtendedTrack(item.FullPath, stream);
if (track.Duration > 0)
{
// 读取专辑图写入内存
// 因为此后该流将关闭无法再次访问
_ = track.EmbeddedPictures;
_tracks.Add(track);
}
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error loading track {item.Name}: {ex.Message}");
}
}
}
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Folder scan error ({folder.Name}): {ex.Message}");
}
}
}
catch (Exception)
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Global scan error: {ex.Message}");
}
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
@@ -337,7 +384,7 @@ namespace BetterLyrics.WinUI3.ViewModels
_playlistTracks = _tracks.Where(t => t.Artist.Equals(SelectedSongsTabInfo.FilterValue, StringComparison.OrdinalIgnoreCase)).ToList();
break;
case CommonSongProperty.Folder:
_playlistTracks = _tracks.Where(t => t.GetParentFolderPath().Equals(SelectedSongsTabInfo.FilterValue, StringComparison.OrdinalIgnoreCase)).ToList();
_playlistTracks = _tracks.Where(t => t.ParentFolderPath.Equals(SelectedSongsTabInfo.FilterValue, StringComparison.OrdinalIgnoreCase)).ToList();
break;
case CommonSongProperty.M3UFilePath:
if (SelectedSongsTabInfo.FilterValue is string path)
@@ -375,9 +422,9 @@ namespace BetterLyrics.WinUI3.ViewModels
t.Artist.Contains(SongSearchQuery, StringComparison.OrdinalIgnoreCase) ||
t.Album.Contains(SongSearchQuery, StringComparison.OrdinalIgnoreCase) ||
// 文件名(包含后缀)
t.GetFileName().Contains(SongSearchQuery, StringComparison.OrdinalIgnoreCase) ||
t.FileName.Contains(SongSearchQuery, StringComparison.OrdinalIgnoreCase) ||
// 文件所在文件夹的路径
t.GetParentFolderPath().Contains(SongSearchQuery, StringComparison.OrdinalIgnoreCase)).ToList();
t.ParentFolderPath.Contains(SongSearchQuery, StringComparison.OrdinalIgnoreCase)).ToList();
}
private void ApplySongOrderType()
@@ -387,25 +434,25 @@ namespace BetterLyrics.WinUI3.ViewModels
case CommonSongProperty.Title:
GroupedTracks = _filteredTracks.GetGroupedBy(
t => LanguageHelper.GetOrderChar(t.Title),
o => ((Track)o).Title
o => ((ExtendedTrack)o).Title
);
break;
case CommonSongProperty.Artist:
GroupedTracks = _filteredTracks.GetGroupedBy(
t => LanguageHelper.GetOrderChar(t.Artist),
o => ((Track)o).Artist
o => ((ExtendedTrack)o).Artist
);
break;
case CommonSongProperty.Album:
GroupedTracks = _filteredTracks.GetGroupedBy(
t => LanguageHelper.GetOrderChar(t.Album),
o => ((Track)o).Album
o => ((ExtendedTrack)o).Album
);
break;
case CommonSongProperty.Folder:
GroupedTracks = _filteredTracks.GetGroupedBy(
t => LanguageHelper.GetOrderChar(t.GetParentFolderName()),
o => ((Track)o).Album
t => LanguageHelper.GetOrderChar(t.ParentFolderName),
o => ((ExtendedTrack)o).Album
);
break;
}

View File

@@ -1,10 +1,16 @@
// 2025/6/23 by Zhe Fang
using BetterLyrics.WinUI3.Helper;
using BetterLyrics.WinUI3.Hooks;
using BetterLyrics.WinUI3.Services.MediaSessionsService;
using BetterLyrics.WinUI3.Views;
using CommunityToolkit.Mvvm.Input;
using System.Collections.Generic;
using System.Threading.Tasks;
using Windows.Storage;
using Windows.Storage.Streams;
using WinRT.Interop;
namespace BetterLyrics.WinUI3.ViewModels
{

View File

@@ -18,7 +18,7 @@ namespace BetterLyrics.WinUI3.Views
{
InitializeComponent();
this.Init("LyricsSearchPageTitle", backdropType: BackdropType.Transparent);
this.Init("LyricsSearchPageTitle");
AppWindow.Closing += AppWindow_Closing;
}

View File

@@ -82,7 +82,7 @@
x:Uid="MusicGalleryPageFileInfoPath"
Link="{x:Bind ViewModel.TrackRightTapped.Path, Mode=OneWay}"
Value="{x:Bind ViewModel.TrackRightTapped.Path, Mode=OneWay}" />
<uc:PropertyRow x:Uid="MusicGalleryPageFileInfoLyrics" Value="{x:Bind ViewModel.TrackRightTapped, Converter={StaticResource TrackToLyricsConverter}, Mode=OneWay}" />
<uc:PropertyRow x:Uid="MusicGalleryPageFileInfoLyrics" Value="{x:Bind ViewModel.TrackRightTapped.RawLyrics, Mode=OneWay}" />
</StackPanel>
</Grid>
</ScrollViewer>
@@ -381,7 +381,7 @@
</MenuBarItemFlyout>
</ListView.ContextFlyout>
<ListView.ItemTemplate>
<DataTemplate x:DataType="atl:Track">
<DataTemplate x:DataType="models:ExtendedTrack">
<Grid
Padding="12"
ColumnSpacing="12"

View File

@@ -54,7 +54,7 @@ namespace BetterLyrics.WinUI3.Views
private async void SongPathHyperlinkButton_Click(object sender, RoutedEventArgs e)
{
await LauncherHelper.SelectAndShowFile(((Track)((HyperlinkButton)sender).DataContext).Path);
await LauncherHelper.SelectAndShowFile(((ExtendedTrack)((HyperlinkButton)sender).DataContext).Path);
}
private async void PlayingQueueListVireItemGrid_Tapped(object sender, TappedRoutedEventArgs e)
@@ -104,7 +104,7 @@ namespace BetterLyrics.WinUI3.Views
private async void AddSongToQueueNextMenuFlyoutItem_Click(object sender, RoutedEventArgs e)
{
bool startPlaying = ViewModel.TrackPlayingQueue.Count == 0;
ViewModel.TrackPlayingQueue.InsertRange(ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex + 1, SongListView.SelectedItems.Cast<Track>().Select(x => new PlayQueueItem(x)));
ViewModel.TrackPlayingQueue.InsertRange(ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex + 1, SongListView.SelectedItems.Cast<ExtendedTrack>().Select(x => new PlayQueueItem(x)));
if (startPlaying)
{
ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex = ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex + 1;
@@ -115,7 +115,7 @@ namespace BetterLyrics.WinUI3.Views
private async void AddSongToQueueEndMenuFlyoutItem_Click(object sender, RoutedEventArgs e)
{
bool startPlaying = ViewModel.TrackPlayingQueue.Count == 0;
ViewModel.TrackPlayingQueue.AddRange(SongListView.SelectedItems.Cast<Track>().Select(x => new PlayQueueItem(x)));
ViewModel.TrackPlayingQueue.AddRange(SongListView.SelectedItems.Cast<ExtendedTrack>().Select(x => new PlayQueueItem(x)));
if (startPlaying)
{
ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex = ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex + 1;
@@ -125,7 +125,7 @@ namespace BetterLyrics.WinUI3.Views
private void SongListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
ViewModel.SelectedTracks = SongListView.SelectedItems.Cast<Track>().ToList();
ViewModel.SelectedTracks = SongListView.SelectedItems.Cast<ExtendedTrack>().ToList();
ViewModel.SelectedTracksTotalDuration = ViewModel.SelectedTracks.Select(x => x.Duration).Sum();
if (SelectAllCheckBox != null)
{
@@ -142,22 +142,22 @@ namespace BetterLyrics.WinUI3.Views
private void ArtistHyperlibkButton_Click(object sender, RoutedEventArgs e)
{
var artist = ((Track)((FrameworkElement)sender).DataContext).Artist;
var artist = ((ExtendedTrack)((FrameworkElement)sender).DataContext).Artist;
var playlist = new SongsTabInfo(artist, "\uEFA9", true, false, CommonSongProperty.Artist, artist);
ViewModel.UpdateSelectedPlaylist(playlist);
}
private void AlbumHyperlibkButton_Click(object sender, RoutedEventArgs e)
{
var album = ((Track)((FrameworkElement)sender).DataContext).Album;
var album = ((ExtendedTrack)((FrameworkElement)sender).DataContext).Album;
var playlist = new SongsTabInfo(album, "\uE93C", true, false, CommonSongProperty.Album, album);
ViewModel.UpdateSelectedPlaylist(playlist);
}
private void PathHyperlibkButton_Click(object sender, RoutedEventArgs e)
{
var track = ((Track)((FrameworkElement)sender).DataContext);
var playlist = new SongsTabInfo(track.GetParentFolderName(), "\uE8B7", true, false, CommonSongProperty.Folder, track.GetParentFolderPath());
var track = ((ExtendedTrack)((FrameworkElement)sender).DataContext);
var playlist = new SongsTabInfo(track.ParentFolderName, "\uE8B7", true, false, CommonSongProperty.Folder, track.ParentFolderPath);
ViewModel.UpdateSelectedPlaylist(playlist);
}
@@ -210,7 +210,7 @@ namespace BetterLyrics.WinUI3.Views
private void SongListViewItemMoreButton_Click(object sender, RoutedEventArgs e)
{
ViewModel.TrackRightTapped = (Track)((FrameworkElement)sender).DataContext;
ViewModel.TrackRightTapped = (ExtendedTrack)((FrameworkElement)sender).DataContext;
SongFileInfoFlyout.ShowAt(sender as FrameworkElement);
}
@@ -260,8 +260,8 @@ namespace BetterLyrics.WinUI3.Views
private async void SongListViewItem_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
{
var displayedTracks = SongListView.Items.Cast<Track>();
var track = (Track)((FrameworkElement)sender).DataContext;
var displayedTracks = SongListView.Items.Cast<ExtendedTrack>();
var track = (ExtendedTrack)((FrameworkElement)sender).DataContext;
// Play all the songs
ViewModel.TrackPlayingQueue.Clear();

View File

@@ -74,6 +74,28 @@
ShadowAmount="{x:Bind LyricsWindowStatus.AlbumArtLayoutSettings.CoverImageShadowAmount, Mode=OneWay}"
Source="{x:Bind ViewModel.MediaSessionsService.AlbumArtBitmapImage, Mode=OneWay}"
SwitchType="{x:Bind LyricsWindowStatus.AlbumArtAreaEffectSettings.ImageSwitchType, Mode=OneWay}" />
<Grid.ContextFlyout>
<Flyout FlyoutPresenterStyle="{StaticResource FlyoutGhostStyle}">
<StackPanel>
<StackPanel Margin="16,8,16,4">
<uc:PropertyRow
x:Uid="SettingsPageWidth"
Unit="px"
Value="{x:Bind ViewModel.MediaSessionsService.AlbumArtBitmapImage.PixelWidth, Mode=OneWay}" />
<uc:PropertyRow
x:Uid="SettingsPageHeight"
Unit="px"
Value="{x:Bind ViewModel.MediaSessionsService.AlbumArtBitmapImage.PixelHeight, Mode=OneWay}" />
</StackPanel>
<Button
HorizontalAlignment="Stretch"
Click="SaveAlbumArtButton_Click"
Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily},
FontSize=16,
Glyph=&#xE74E;}" />
</StackPanel>
</Flyout>
</Grid.ContextFlyout>
</Grid>
<!-- Song info -->

View File

@@ -3,6 +3,7 @@
using BetterLyrics.WinUI3.Controls;
using BetterLyrics.WinUI3.Enums;
using BetterLyrics.WinUI3.Helper;
using BetterLyrics.WinUI3.Hooks;
using BetterLyrics.WinUI3.Models;
using BetterLyrics.WinUI3.Models.Settings;
using BetterLyrics.WinUI3.Services.MediaSessionsService;
@@ -17,7 +18,11 @@ using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Documents;
using Microsoft.UI.Xaml.Media;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Windows.Storage;
using Windows.Storage.Streams;
namespace BetterLyrics.WinUI3.Views
{
@@ -652,5 +657,34 @@ namespace BetterLyrics.WinUI3.Views
}
}
private async void SaveAlbumArtButton_Click(object sender, RoutedEventArgs e)
{
var sourceStream = ViewModel.MediaSessionsService.AlbumArtBitmapStream;
if (sourceStream == null) return;
var window = WindowHook.GetWindows<NowPlayingWindow>().FirstOrDefault(x => x.LyricsWindowStatus == LyricsWindowStatus);
if (window == null) return;
IDictionary<string, IList<string>> fileTypeChoices = new Dictionary<string, IList<string>>()
{
{ "PNG", new List<string>() { ".png" } },
{ "JPEG", new List<string>() { ".jpg", ".jpeg" } }
};
var file = await PickerHelper.PickSaveFileAsync(window, fileTypeChoices);
if (file != null)
{
using (IRandomAccessStream destStream = await file.OpenAsync(FileAccessMode.ReadWrite))
{
sourceStream.Seek(0);
await RandomAccessStream.CopyAsync(sourceStream, destStream);
await destStream.FlushAsync();
ToastHelper.ShowToast("ActionCompleted", null, InfoBarSeverity.Success);
}
}
}
}
}