mirror of
https://github.com/jayfunc/BetterLyrics.git
synced 2026-01-12 19:24:55 +08:00
Compare commits
138 Commits
v1.1.221.0
...
v1.2.243.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a13bb6e8e4 | ||
|
|
0b436c1ea9 | ||
|
|
5d332fdfc6 | ||
|
|
572d2cd8ba | ||
|
|
1e5a95c55e | ||
|
|
18ce6d3a57 | ||
|
|
427aed6857 | ||
|
|
ebfa484a2e | ||
|
|
3ef9d81bea | ||
|
|
e999d07834 | ||
|
|
838b8de94f | ||
|
|
b3059dbeb1 | ||
|
|
6fea88a6a1 | ||
|
|
abca9ae5fb | ||
|
|
a062897e1a | ||
|
|
8b4748df1b | ||
|
|
1df5ea6bab | ||
|
|
c576635af2 | ||
|
|
c8590202ec | ||
|
|
2dc8b1283f | ||
|
|
c482edea0f | ||
|
|
315722252c | ||
|
|
32ba453264 | ||
|
|
d4902329bb | ||
|
|
83aee8948b | ||
|
|
1f9fab3228 | ||
|
|
7a3a659dfc | ||
|
|
a14afd3eb5 | ||
|
|
c2af7f3186 | ||
|
|
cd026dd2bf | ||
|
|
4bc1a9975d | ||
|
|
07eecf0930 | ||
|
|
35fba5abb0 | ||
|
|
03ef231a3f | ||
|
|
f41879f4e5 | ||
|
|
bda7510ed6 | ||
|
|
5ec8c7c61f | ||
|
|
7e6bd9dade | ||
|
|
56244cb793 | ||
|
|
cb5f70ab55 | ||
|
|
3cc018bb1f | ||
|
|
c517d2b008 | ||
|
|
e79f2a0223 | ||
|
|
39122b9147 | ||
|
|
accbdc1806 | ||
|
|
de014d1ad7 | ||
|
|
cc2ce5f8cf | ||
|
|
2a2d80436e | ||
|
|
ce3f79f35c | ||
|
|
12e6000cb3 | ||
|
|
c1dc684411 | ||
|
|
69ea2cb495 | ||
|
|
e2ee03c4be | ||
|
|
c6fe33d6ae | ||
|
|
7744e145fa | ||
|
|
0284b1de81 | ||
|
|
108c2cd34b | ||
|
|
390e30f7f5 | ||
|
|
900774668d | ||
|
|
6ca2d1f897 | ||
|
|
164bd077b8 | ||
|
|
8ec71fcfb7 | ||
|
|
f39ad54df8 | ||
|
|
9b809983df | ||
|
|
8006b3a443 | ||
|
|
26a7454de2 | ||
|
|
0793a074cf | ||
|
|
125bf1682e | ||
|
|
48bdffb2fe | ||
|
|
d324a7552f | ||
|
|
78c308c393 | ||
|
|
a1bba00db6 | ||
|
|
0787f5b111 | ||
|
|
884026594b | ||
|
|
b0a777db8d | ||
|
|
83f3a3bd6d | ||
|
|
bfb2ed29e5 | ||
|
|
131a0f0eb1 | ||
|
|
ac2a7b3f7b | ||
|
|
36eea7f8f2 | ||
|
|
6b338deb55 | ||
|
|
af323ecd00 | ||
|
|
c79d01c75b | ||
|
|
b51ec1e60f | ||
|
|
7fe925bcba | ||
|
|
0626472d66 | ||
|
|
33099bc186 | ||
|
|
e653efc227 | ||
|
|
074fef3faf | ||
|
|
029cbbd343 | ||
|
|
802b2a4c1c | ||
|
|
eccc4d519c | ||
|
|
5f274ea28a | ||
|
|
aa1a1f5d58 | ||
|
|
3a56d53487 | ||
|
|
bbc5eb772c | ||
|
|
05b491052b | ||
|
|
8accbf0431 | ||
|
|
1174209c2a | ||
|
|
23ed719046 | ||
|
|
a34f00662e | ||
|
|
f783314258 | ||
|
|
215a39c5d5 | ||
|
|
16bcef5f64 | ||
|
|
fbba9a3c36 | ||
|
|
f205ab0364 | ||
|
|
10314f3c2f | ||
|
|
b4710e87d3 | ||
|
|
282a934cd2 | ||
|
|
b4c4e394ef | ||
|
|
17cfdf37bd | ||
|
|
900a8e1e7c | ||
|
|
ea9a9c2f5f | ||
|
|
0c4d02b337 | ||
|
|
d137d82ecf | ||
|
|
02551e2053 | ||
|
|
026926e9b8 | ||
|
|
4c811db16a | ||
|
|
6f83fa11db | ||
|
|
bc8e15c144 | ||
|
|
85de1eb2cd | ||
|
|
d2bf19ed3d | ||
|
|
43c205c839 | ||
|
|
9664b1ab78 | ||
|
|
08c5f6b515 | ||
|
|
260de40f81 | ||
|
|
c00d0eb005 | ||
|
|
32e761724c | ||
|
|
9fd08af582 | ||
|
|
266dcfc930 | ||
|
|
8764585f2c | ||
|
|
91ab3a48c0 | ||
|
|
80fa34d9e8 | ||
|
|
b4ca4fd990 | ||
|
|
86527f6b82 | ||
|
|
d8066bc683 | ||
|
|
b261a86791 | ||
|
|
34f2a51b74 |
@@ -12,7 +12,7 @@
|
||||
<Identity
|
||||
Name="37412.BetterLyrics"
|
||||
Publisher="CN=E1428B0E-DC1D-4EA4-ACB1-4556569D5BA9"
|
||||
Version="1.1.221.0" />
|
||||
Version="1.2.243.0" />
|
||||
|
||||
<mp:PhoneIdentity PhoneProductId="ca4a4830-fc19-40d9-b823-53e2bff3d816" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
x:Class="BetterLyrics.WinUI3.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converter="using:BetterLyrics.WinUI3.Converter"
|
||||
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
|
||||
xmlns:globalization="using:Windows.Globalization"
|
||||
xmlns:local="using:BetterLyrics.WinUI3"
|
||||
xmlns:media="using:CommunityToolkit.WinUI.Media">
|
||||
@@ -13,8 +11,14 @@
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<!-- Merged dictionaries here -->
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
|
||||
<ResourceDictionary Source="ms-appx:///CommunityToolkit.WinUI.Controls.Segmented/Segmented/Segmented.xaml" />
|
||||
<ResourceDictionary Source="ms-appx:///DevWinUI.Controls/Themes/Generic.xaml" />
|
||||
|
||||
<ResourceDictionary Source="/Styles/Converters.xaml" />
|
||||
<ResourceDictionary Source="/Styles/InteractiveListViewHeaderStyle.xaml" />
|
||||
<ResourceDictionary Source="/Styles/GhostSliderStyle.xaml" />
|
||||
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- Theme -->
|
||||
@@ -42,45 +46,6 @@
|
||||
<ExponentialEase x:Key="EaseOut" EasingMode="EaseOut" />
|
||||
<ExponentialEase x:Key="EaseIn" EasingMode="EaseIn" />
|
||||
|
||||
<!-- Converter -->
|
||||
<converter:EnumToIntConverter x:Key="EnumToIntConverter" />
|
||||
<converter:ColorToBrushConverter x:Key="ColorToBrushConverter" />
|
||||
<converter:MatchedLocalFilesPathToVisibilityConverter x:Key="MatchedLocalFilesPathToVisibilityConverter" />
|
||||
<converter:IntToCornerRadius x:Key="IntToCornerRadius" />
|
||||
<converter:CornerRadiusToDoubleConverter x:Key="CornerRadiusToDoubleConverter" />
|
||||
<converter:LyricsSearchProviderToDisplayNameConverter x:Key="LyricsSearchProviderToDisplayNameConverter" />
|
||||
<converter:TranslationSearchProviderToDisplayNameConverter x:Key="TranslationSearchProviderToDisplayNameConverter" />
|
||||
<converter:TransliterationSearchProviderToDisplayNameConverter x:Key="TransliterationSearchProviderToDisplayNameConverter" />
|
||||
<converter:AlbumArtSearchProviderToDisplayNameConverter x:Key="AlbumArtSearchProviderToDisplayNameConverter" />
|
||||
<converter:SecondsToFormattedTimeConverter x:Key="SecondsToFormattedTimeConverter" />
|
||||
<converter:MillisecondsToFormattedTimeConverter x:Key="MillisecondsToFormattedTimeConverter" />
|
||||
<converter:FPSToTimeSpanConverter x:Key="FPSToTimeSpanConverter" />
|
||||
<converter:ShortcutToStringConverter x:Key="ShortcutToStringConverter" />
|
||||
<converter:BoolNegationToVisibilityConverter x:Key="BoolNegationToVisibilityConverter" />
|
||||
<converter:BoolToOpacityConverter x:Key="BoolToOpacityConverter" />
|
||||
<converter:BoolToPartialOpacityConverter x:Key="BoolToPartialOpacityConverter" />
|
||||
<converter:BoolNegationToOpacityConverter x:Key="BoolNegationToOpacityConverter" />
|
||||
<converter:RectToMarginConverter x:Key="RectToMarginConverter" />
|
||||
<converter:LanguageCodeToDisplayedNameConverter x:Key="LanguageCodeToDisplayedNameConverter" />
|
||||
<converter:ByteArrayToImageConverter x:Key="ByteArrayToImageConverter" />
|
||||
<converter:DisplayLanguageCodeToIndexConverter x:Key="DisplayLanguageCodeToIndexConverter" />
|
||||
<converter:PathToParentFolderConverter x:Key="PathToParentFolderConverter" />
|
||||
<converter:IntToBoolConverter x:Key="IntToBoolConverter" />
|
||||
<converter:IndexToDisplayConverter x:Key="IndexToDisplayConverter" />
|
||||
<converter:IntToDoubleConverter x:Key="IntToDoubleConverter" />
|
||||
<converter:MillisecondsToSecondsConverter x:Key="MillisecondsToSecondsConverter" />
|
||||
<converter:PictureInfosToImageSourceConverter x:Key="PictureInfosToImageSourceConverter" />
|
||||
<converter:LyricsFontWeightToFontWeightConverter x:Key="LyricsFontWeightToFontWeightConverter" />
|
||||
<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" />
|
||||
<converters:ColorToDisplayNameConverter x:Key="ColorToDisplayNameConverter" />
|
||||
<converters:CollectionVisibilityConverter x:Key="CollectionVisibilityConverter" />
|
||||
|
||||
<x:Double x:Key="SettingsCardSpacing">4</x:Double>
|
||||
|
||||
<!-- Style -->
|
||||
@@ -96,7 +61,7 @@
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="14,6,14,9" />
|
||||
<Setter Property="Padding" Value="16,9,16,9" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style x:Key="GhostButtonStyle" TargetType="Button">
|
||||
@@ -108,7 +73,7 @@
|
||||
</Style>
|
||||
<Style
|
||||
x:Key="TitleBarToggleButtonStyle"
|
||||
BasedOn="{StaticResource ToggleButtonRevealStyle}"
|
||||
BasedOn="{StaticResource DefaultToggleButtonStyle}"
|
||||
TargetType="ToggleButton">
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
@@ -116,7 +81,10 @@
|
||||
<Setter Property="Padding" Value="14,6,14,9" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style x:Key="GhostToggleButtonStyle" TargetType="ToggleButton">
|
||||
<Style
|
||||
x:Key="GhostToggleButtonStyle"
|
||||
BasedOn="{StaticResource DefaultToggleButtonStyle}"
|
||||
TargetType="ToggleButton">
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
@@ -130,190 +98,6 @@
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="GhostSliderStyle" TargetType="Slider">
|
||||
<Setter Property="Background" Value="{ThemeResource ControlStrokeColorOnAccentDefaultBrush}" />
|
||||
<Setter Property="BorderThickness" Value="{ThemeResource SliderBorderThemeThickness}" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" />
|
||||
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
|
||||
<Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" />
|
||||
<Setter Property="ManipulationMode" Value="None" />
|
||||
<Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" />
|
||||
<Setter Property="FocusVisualMargin" Value="-7,0,-7,0" />
|
||||
<Setter Property="IsFocusEngagementEnabled" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Slider">
|
||||
<Grid Margin="{TemplateBinding Padding}">
|
||||
<Grid.Resources>
|
||||
<Style x:Key="SliderThumbStyle" TargetType="Thumb">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Background" Value="{ThemeResource TextFillColorPrimaryBrush}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="0,1,1,0" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Grid.Resources>
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<ContentPresenter
|
||||
x:Name="HeaderContentPresenter"
|
||||
Grid.Row="0"
|
||||
Margin="{ThemeResource SliderTopHeaderMargin}"
|
||||
x:DeferLoadStrategy="Lazy"
|
||||
Content="{TemplateBinding Header}"
|
||||
ContentTemplate="{TemplateBinding HeaderTemplate}"
|
||||
FontWeight="{ThemeResource SliderHeaderThemeFontWeight}"
|
||||
Foreground="{ThemeResource SliderHeaderForeground}"
|
||||
TextWrapping="Wrap"
|
||||
Visibility="Collapsed" />
|
||||
<Grid
|
||||
x:Name="SliderContainer"
|
||||
Grid.Row="1"
|
||||
Background="{ThemeResource SliderContainerBackground}"
|
||||
Control.IsTemplateFocusTarget="True">
|
||||
<Grid x:Name="HorizontalTemplate" MinHeight="{ThemeResource SliderHorizontalHeight}">
|
||||
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="{ThemeResource SliderPreContentMargin}" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="{ThemeResource SliderPostContentMargin}" />
|
||||
</Grid.RowDefinitions>
|
||||
<Rectangle
|
||||
x:Name="HorizontalTrackRect"
|
||||
Grid.Row="1"
|
||||
Grid.ColumnSpan="3"
|
||||
Height="2"
|
||||
Fill="{TemplateBinding Background}" />
|
||||
<Rectangle
|
||||
x:Name="HorizontalDecreaseRect"
|
||||
Grid.Row="1"
|
||||
Fill="{TemplateBinding Foreground}" />
|
||||
<TickBar
|
||||
x:Name="TopTickBar"
|
||||
Grid.ColumnSpan="3"
|
||||
Height="{ThemeResource SliderOutsideTickBarThemeHeight}"
|
||||
Margin="0,0,0,4"
|
||||
VerticalAlignment="Bottom"
|
||||
Fill="{ThemeResource SliderTickBarFill}"
|
||||
Visibility="Collapsed" />
|
||||
<TickBar
|
||||
x:Name="HorizontalInlineTickBar"
|
||||
Grid.Row="1"
|
||||
Grid.ColumnSpan="3"
|
||||
Height="2"
|
||||
Fill="{ThemeResource SliderInlineTickBarFill}"
|
||||
Visibility="Collapsed" />
|
||||
<TickBar
|
||||
x:Name="BottomTickBar"
|
||||
Grid.Row="2"
|
||||
Grid.ColumnSpan="3"
|
||||
Height="{ThemeResource SliderOutsideTickBarThemeHeight}"
|
||||
Margin="0,4,0,0"
|
||||
VerticalAlignment="Top"
|
||||
Fill="{ThemeResource SliderTickBarFill}"
|
||||
Visibility="Collapsed" />
|
||||
<Thumb
|
||||
x:Name="HorizontalThumb"
|
||||
Grid.Row="0"
|
||||
Grid.RowSpan="3"
|
||||
Grid.Column="1"
|
||||
Width="2"
|
||||
Height="2"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
DataContext="{TemplateBinding Value}"
|
||||
FocusVisualMargin="-14,-6,-14,-6"
|
||||
Style="{StaticResource SliderThumbStyle}" />
|
||||
</Grid>
|
||||
<Grid
|
||||
x:Name="VerticalTemplate"
|
||||
MinWidth="{ThemeResource SliderVerticalWidth}"
|
||||
Visibility="Collapsed">
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="{ThemeResource SliderPreContentMargin}" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="{ThemeResource SliderPostContentMargin}" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Rectangle
|
||||
x:Name="VerticalTrackRect"
|
||||
Grid.RowSpan="3"
|
||||
Grid.Column="1"
|
||||
Width="{ThemeResource SliderTrackThemeHeight}"
|
||||
Fill="{TemplateBinding Background}" />
|
||||
<Rectangle
|
||||
x:Name="VerticalDecreaseRect"
|
||||
Grid.Row="2"
|
||||
Grid.Column="1"
|
||||
Fill="{TemplateBinding Foreground}" />
|
||||
<TickBar
|
||||
x:Name="LeftTickBar"
|
||||
Grid.RowSpan="3"
|
||||
Width="{ThemeResource SliderOutsideTickBarThemeHeight}"
|
||||
Margin="0,0,4,0"
|
||||
HorizontalAlignment="Right"
|
||||
Fill="{ThemeResource SliderTickBarFill}"
|
||||
Visibility="Collapsed" />
|
||||
<TickBar
|
||||
x:Name="VerticalInlineTickBar"
|
||||
Grid.RowSpan="3"
|
||||
Grid.Column="1"
|
||||
Width="{ThemeResource SliderTrackThemeHeight}"
|
||||
Fill="{ThemeResource SliderInlineTickBarFill}"
|
||||
Visibility="Collapsed" />
|
||||
<TickBar
|
||||
x:Name="RightTickBar"
|
||||
Grid.RowSpan="3"
|
||||
Grid.Column="2"
|
||||
Width="{ThemeResource SliderOutsideTickBarThemeHeight}"
|
||||
Margin="4,0,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Fill="{ThemeResource SliderTickBarFill}"
|
||||
Visibility="Collapsed" />
|
||||
<Thumb
|
||||
x:Name="VerticalThumb"
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3"
|
||||
Width="24"
|
||||
Height="8"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
DataContext="{TemplateBinding Value}"
|
||||
FocusVisualMargin="-6,-14,-6,-14"
|
||||
Style="{StaticResource SliderThumbStyle}" />
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
x:Key="ListViewStretchedItemContainerStyle"
|
||||
BasedOn="{StaticResource DefaultListViewItemStyle}"
|
||||
@@ -357,10 +141,6 @@
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" />
|
||||
</Style>
|
||||
|
||||
<StaticResource x:Key="ToggleButtonBackgroundChecked" ResourceKey="TextFillColorPrimaryBrush" />
|
||||
<StaticResource x:Key="ToggleButtonBackgroundCheckedPointerOver" ResourceKey="TextFillColorPrimaryBrush" />
|
||||
<StaticResource x:Key="ToggleButtonBackgroundCheckedPressed" ResourceKey="TextFillColorPrimaryBrush" />
|
||||
|
||||
<!-- Dimensions -->
|
||||
|
||||
<!-- Fonts -->
|
||||
|
||||
@@ -1,51 +1,56 @@
|
||||
// 2025/6/23 by Zhe Fang
|
||||
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Hooks;
|
||||
using BetterLyrics.WinUI3.Models.Settings;
|
||||
using BetterLyrics.WinUI3.Models.Db;
|
||||
using BetterLyrics.WinUI3.Services.AlbumArtSearchService;
|
||||
using BetterLyrics.WinUI3.Services.DiscordService;
|
||||
using BetterLyrics.WinUI3.Services.FileSystemService;
|
||||
using BetterLyrics.WinUI3.Services.GSMTCService;
|
||||
using BetterLyrics.WinUI3.Services.LastFMService;
|
||||
using BetterLyrics.WinUI3.Services.LibWatcherService;
|
||||
using BetterLyrics.WinUI3.Services.LocalizationService;
|
||||
using BetterLyrics.WinUI3.Services.LyricsSearchService;
|
||||
using BetterLyrics.WinUI3.Services.MediaSessionsService;
|
||||
using BetterLyrics.WinUI3.Services.PlayHistoryService;
|
||||
using BetterLyrics.WinUI3.Services.SettingsService;
|
||||
using BetterLyrics.WinUI3.Services.SMTCService;
|
||||
using BetterLyrics.WinUI3.Services.TranslationService;
|
||||
using BetterLyrics.WinUI3.Services.TransliterationService;
|
||||
using BetterLyrics.WinUI3.ViewModels;
|
||||
using BetterLyrics.WinUI3.Views;
|
||||
using CommunityToolkit.Mvvm.DependencyInjection;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.Windows.ApplicationModel.Resources;
|
||||
using Microsoft.Windows.Globalization;
|
||||
using Microsoft.Windows.AppLifecycle; // 关键:App生命周期管理
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Vanara.PInvoke;
|
||||
|
||||
namespace BetterLyrics.WinUI3
|
||||
{
|
||||
public partial class App : Application
|
||||
{
|
||||
|
||||
private Window? m_window;
|
||||
private readonly ILogger<App> _logger;
|
||||
|
||||
public static new App Current => (App)Application.Current;
|
||||
|
||||
private static Mutex? _instanceMutex;
|
||||
private readonly string _appKey = Windows.ApplicationModel.Package.Current.Id.FamilyName;
|
||||
|
||||
public App()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
// Must be done before InitializeComponent
|
||||
if (!TryHandleSingleInstance())
|
||||
{
|
||||
// 如果移交成功直接退出当前进程
|
||||
Environment.Exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureSingleInstance();
|
||||
this.InitializeComponent();
|
||||
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
PathHelper.EnsureDirectories();
|
||||
@@ -53,29 +58,94 @@ namespace BetterLyrics.WinUI3
|
||||
|
||||
_logger = Ioc.Default.GetRequiredService<ILogger<App>>();
|
||||
|
||||
// 注册全局异常捕获
|
||||
UnhandledException += App_UnhandledException;
|
||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||
AppDomain.CurrentDomain.FirstChanceException += CurrentDomain_FirstChanceException;
|
||||
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
|
||||
}
|
||||
|
||||
private void EnsureSingleInstance()
|
||||
/// <summary>
|
||||
/// 处理单实例逻辑。
|
||||
/// 返回 true 表示我是主实例,继续运行。
|
||||
/// 返回 false 表示我是第二个实例,已通知主实例,我应该退出。
|
||||
/// </summary>
|
||||
private bool TryHandleSingleInstance()
|
||||
{
|
||||
_instanceMutex = new Mutex(true, Constants.App.AppName, out bool createdNew);
|
||||
// 尝试查找或注册当前实例
|
||||
var mainInstance = AppInstance.FindOrRegisterForKey(_appKey);
|
||||
|
||||
if (!createdNew)
|
||||
// 如果当前实例就是注册的那个主实例
|
||||
if (mainInstance.IsCurrent)
|
||||
{
|
||||
User32.MessageBox(HWND.NULL, new ResourceLoader().GetString("TryRunMultipleInstance"), null, User32.MB_FLAGS.MB_APPLMODAL);
|
||||
Environment.Exit(0);
|
||||
// 监听 "Activated" 事件。
|
||||
// 当第二个实例启动并重定向过来时,这个事件会被触发。
|
||||
mainInstance.Activated += OnMainInstanceActivated;
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 我不是主实例,我是后来者。
|
||||
// 获取当前实例的激活参数(比如是通过文件双击打开的,这里能拿到文件路径)
|
||||
var args = AppInstance.GetCurrent().GetActivatedEventArgs();
|
||||
|
||||
// 将激活请求重定向给主实例
|
||||
// 注意:这里是同步等待,确保发送成功后再退出
|
||||
try
|
||||
{
|
||||
mainInstance.RedirectActivationToAsync(args).AsTask().Wait();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 即使重定向失败,作为第二个实例也应该退出
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
/// <summary>
|
||||
/// 当第二个实例试图启动时,主实例会收到此回调
|
||||
/// </summary>
|
||||
private void OnMainInstanceActivated(object? sender, AppActivationArguments e)
|
||||
{
|
||||
// 这个事件是在后台线程触发的,必须切回 UI 线程操作窗口
|
||||
m_window?.DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
HandleActivation();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 唤醒逻辑
|
||||
/// </summary>
|
||||
private void HandleActivation()
|
||||
{
|
||||
WindowHook.OpenOrShowWindow<LyricsWindowSwitchWindow>();
|
||||
}
|
||||
|
||||
protected override async void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
// 初始化数据库
|
||||
await EnsureDatabasesAsync();
|
||||
|
||||
var settingsService = Ioc.Default.GetRequiredService<ISettingsService>();
|
||||
var fileSystemService = Ioc.Default.GetRequiredService<IFileSystemService>();
|
||||
|
||||
WindowHook.OpenOrShowWindow<SystemTrayWindow>();
|
||||
// 开始后台扫描任务
|
||||
foreach (var item in settingsService.AppSettings.LocalMediaFolders)
|
||||
{
|
||||
if (item.LastSyncTime == null)
|
||||
{
|
||||
_ = Task.Run(async () => await fileSystemService.ScanMediaFolderAsync(item, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
fileSystemService.StartAllFolderTimers();
|
||||
|
||||
// 初始化托盘
|
||||
m_window = WindowHook.OpenOrShowWindow<SystemTrayWindow>();
|
||||
|
||||
// 根据设置打开歌词窗口
|
||||
if (settingsService.AppSettings.GeneralSettings.AutoStartLyricsWindow)
|
||||
{
|
||||
var defaultStatus = settingsService.AppSettings.WindowBoundsRecords.Where(x => x.IsDefault);
|
||||
@@ -91,38 +161,136 @@ namespace BetterLyrics.WinUI3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据设置自动打开主界面
|
||||
if (settingsService.AppSettings.MusicGallerySettings.AutoOpen)
|
||||
{
|
||||
WindowHook.OpenOrShowWindow<MusicGalleryWindow>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureDatabasesAsync()
|
||||
{
|
||||
var playHistoryFactory = Ioc.Default.GetRequiredService<IDbContextFactory<PlayHistoryDbContext>>();
|
||||
var fileCacheFactory = Ioc.Default.GetRequiredService<IDbContextFactory<FilesIndexDbContext>>();
|
||||
|
||||
await SafeInitDatabaseAsync(
|
||||
"PlayHistory",
|
||||
PathHelper.PlayHistoryPath,
|
||||
async () =>
|
||||
{
|
||||
using var db = await playHistoryFactory.CreateDbContextAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
},
|
||||
isCritical: true
|
||||
);
|
||||
|
||||
await SafeInitDatabaseAsync(
|
||||
"FileCache",
|
||||
PathHelper.FilesIndexPath,
|
||||
async () =>
|
||||
{
|
||||
using var db = await fileCacheFactory.CreateDbContextAsync();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
},
|
||||
isCritical: false
|
||||
);
|
||||
}
|
||||
|
||||
private async Task SafeInitDatabaseAsync(string dbName, string dbPath, Func<Task> initAction, bool isCritical)
|
||||
{
|
||||
try
|
||||
{
|
||||
await initAction();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[DB Error] {dbName} init failed: {ex.Message}");
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(dbPath))
|
||||
{
|
||||
// 尝试清理连接池
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
if (isCritical)
|
||||
{
|
||||
var backupPath = dbPath + ".bak_" + DateTime.Now.ToString("yyyyMMddHHmmss");
|
||||
File.Move(dbPath, backupPath, true);
|
||||
await ShowErrorDialogAsync("Database Recovery", $"Database {dbName} is damaged, the old database has been backed up to {backupPath}, and the program will create a new database.");
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Delete(dbPath);
|
||||
}
|
||||
}
|
||||
await initAction();
|
||||
System.Diagnostics.Debug.WriteLine($"[DB Info] {dbName} recovered successfully.");
|
||||
}
|
||||
catch (Exception fatalEx)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[] : {fatalEx.Message}");
|
||||
await ShowErrorDialogAsync("Fatal Error", $"{dbName} recovery failed, please delete the file at {dbPath} and try again by restarting the program. ({fatalEx.Message})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowErrorDialogAsync(string title, string content)
|
||||
{
|
||||
// 这里假设 m_window 已经存在。如果没有显示主窗口,这个弹窗可能无法显示。
|
||||
// 在 App 启动极早期的错误,可能需要退化为 Log 或者 System.Diagnostics.Process.Start 打开记事本报错
|
||||
if (m_window != null)
|
||||
{
|
||||
m_window.DispatcherQueue.TryEnqueue(async () =>
|
||||
{
|
||||
var dialog = new Microsoft.UI.Xaml.Controls.ContentDialog
|
||||
{
|
||||
Title = title,
|
||||
Content = content,
|
||||
CloseButtonText = "OK",
|
||||
XamlRoot = m_window.Content?.XamlRoot // 确保 Content 不为空
|
||||
};
|
||||
if (dialog.XamlRoot != null) await dialog.ShowAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureServices()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Is(Serilog.Events.LogEventLevel.Verbose)
|
||||
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Error)
|
||||
.WriteTo.File(PathHelper.LogFilePattern, rollingInterval: RollingInterval.Day)
|
||||
.CreateLogger();
|
||||
|
||||
// Register services
|
||||
Ioc.Default.ConfigureServices(
|
||||
new ServiceCollection()
|
||||
// 数据库工厂
|
||||
.AddDbContextFactory<PlayHistoryDbContext>(options => options.UseSqlite($"Data Source={PathHelper.PlayHistoryPath}"))
|
||||
.AddDbContextFactory<FilesIndexDbContext>(options => options.UseSqlite($"Data Source={PathHelper.FilesIndexPath}"))
|
||||
|
||||
// 日志
|
||||
.AddLogging(loggingBuilder =>
|
||||
{
|
||||
loggingBuilder.ClearProviders();
|
||||
loggingBuilder.AddSerilog();
|
||||
})
|
||||
|
||||
// Services
|
||||
.AddSingleton<ISettingsService, SettingsService>()
|
||||
.AddSingleton<IMediaSessionsService, MediaSessionsService>()
|
||||
.AddSingleton<ISMTCService, SMTCService>()
|
||||
.AddSingleton<IGSMTCService, GSMTCService>()
|
||||
.AddSingleton<IAlbumArtSearchService, AlbumArtSearchService>()
|
||||
.AddSingleton<ILyricsSearchService, LyricsSearchService>()
|
||||
.AddSingleton<ILibWatcherService, LibWatcherService>()
|
||||
.AddSingleton<ITranslationService, TranslationService>()
|
||||
.AddSingleton<ITransliterationService, TransliterationService>()
|
||||
.AddSingleton<ILastFMService, LastFMService>()
|
||||
.AddSingleton<IDiscordService, DiscordService>()
|
||||
.AddSingleton<ILocalizationService, LocalizationService>()
|
||||
.AddSingleton<IFileSystemService, FileSystemService>()
|
||||
.AddSingleton<IPlayHistoryService, PlayHistoryService>()
|
||||
|
||||
// ViewModels
|
||||
.AddSingleton<AppSettingsControlViewModel>()
|
||||
.AddSingleton<PlaybackSettingsControlViewModel>()
|
||||
@@ -137,6 +305,8 @@ namespace BetterLyrics.WinUI3
|
||||
.AddSingleton<MusicGalleryPageViewModel>()
|
||||
.AddSingleton<AboutControlViewModel>()
|
||||
.AddSingleton<MusicGalleryWindowViewModel>()
|
||||
.AddSingleton<StatsDashboardControlViewModel>()
|
||||
.AddSingleton<PlayQueueViewModel>()
|
||||
|
||||
.AddTransient<NowPlayingWindowViewModel>()
|
||||
.AddTransient<NowPlayingPageViewModel>()
|
||||
@@ -154,7 +324,8 @@ namespace BetterLyrics.WinUI3
|
||||
|
||||
private void CurrentDomain_FirstChanceException(object? sender, System.Runtime.ExceptionServices.FirstChanceExceptionEventArgs e)
|
||||
{
|
||||
_logger.LogError(e.Exception, "CurrentDomain_FirstChanceException");
|
||||
// FirstChance 异常非常多(比如内部 try-catch 也会触发),通常建议只在 Debug 模式记录,或者过滤特定类型
|
||||
// _logger.LogError(e.Exception, "CurrentDomain_FirstChanceException");
|
||||
}
|
||||
|
||||
private void CurrentDomain_UnhandledException(object sender, System.UnhandledExceptionEventArgs e)
|
||||
@@ -167,4 +338,4 @@ namespace BetterLyrics.WinUI3
|
||||
_logger.LogError(e.Exception, "TaskScheduler_UnobservedTaskException");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
BetterLyrics.WinUI3/BetterLyrics.WinUI3/Assets/Folder.png
Normal file
BIN
BetterLyrics.WinUI3/BetterLyrics.WinUI3/Assets/Folder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 593 KiB |
@@ -42,12 +42,17 @@
|
||||
<None Remove="Controls\LyricsWindowSwitchControl.xaml" />
|
||||
<None Remove="Controls\MediaSettingsControl.xaml" />
|
||||
<None Remove="Controls\NowPlayingBar.xaml" />
|
||||
<None Remove="Controls\PatronControl.xaml" />
|
||||
<None Remove="Controls\PlaybackSettingsControl.xaml" />
|
||||
<None Remove="Controls\PlayQueue.xaml" />
|
||||
<None Remove="Controls\PropertyRow.xaml" />
|
||||
<None Remove="Controls\RemoteServerConfigControl.xaml" />
|
||||
<None Remove="Controls\ShortcutTextBox.xaml" />
|
||||
<None Remove="Controls\StatsDashboardControl.xaml" />
|
||||
<None Remove="Controls\SystemTray.xaml" />
|
||||
<None Remove="Controls\WindowSettingsControl.xaml" />
|
||||
<None Remove="Styles\GhostSliderStyle.xaml" />
|
||||
<None Remove="Styles\InteractiveListViewHeaderStyle.xaml" />
|
||||
<None Remove="Views\LyricsSearchWindow.xaml" />
|
||||
<None Remove="Views\LyricsWindowSwitchWindow.xaml" />
|
||||
<None Remove="Views\MusicGalleryPage.xaml" />
|
||||
@@ -69,6 +74,7 @@
|
||||
<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.Controls.Sizers" 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" />
|
||||
@@ -84,7 +90,12 @@
|
||||
<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" />
|
||||
<PackageReference Include="LiveChartsCore.SkiaSharpView.WinUI" Version="2.0.0-rc6.1" />
|
||||
<PackageReference Include="Lyricify.Lyrics.Helper-NativeAot" Version="0.1.4-alpha.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Graphics.Win2D" Version="1.3.2" />
|
||||
@@ -127,6 +138,10 @@
|
||||
<ItemGroup>
|
||||
<TrimmerRootAssembly Include="FlaUI.UIA3" />
|
||||
<TrimmerRootAssembly Include="Interop.UIAutomationClient" />
|
||||
<TrimmerRootAssembly Include="Microsoft.EntityFrameworkCore" />
|
||||
<TrimmerRootAssembly Include="Microsoft.EntityFrameworkCore.Abstractions" />
|
||||
<TrimmerRootAssembly Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<TrimmerRootAssembly Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<TrimmerRootAssembly Include="NAudio.Wasapi" />
|
||||
<TrimmerRootAssembly Include="TagLibSharp" />
|
||||
<TrimmerRootAssembly Include="Vanara.PInvoke.DwmApi" />
|
||||
@@ -169,6 +184,9 @@
|
||||
<Content Update="Assets\EmptyState.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="Assets\Folder.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="Assets\foobar2000.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
@@ -211,6 +229,9 @@
|
||||
<Content Update="Assets\NetEaseCloudMusic.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="Assets\OriginalSoundHQPlayer.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="Assets\Page.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
@@ -226,6 +247,9 @@
|
||||
<Content Update="Assets\Question.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="Assets\RevolvingHearts.gif">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="Assets\SaltPlayerForWindows.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
@@ -242,6 +266,31 @@
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Controls\PatronControl.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Styles\GhostSliderStyle.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Styles\Converters.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Controls\PlayQueue.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Controls\StatsDashboardControl.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Controls\FontFamilyAutoSuggestBox.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
@@ -387,6 +436,11 @@
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Styles\InteractiveListViewHeaderStyle.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<!-- Publish Properties -->
|
||||
<PropertyGroup>
|
||||
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace BetterLyrics.WinUI3.Constants
|
||||
{
|
||||
public static class PlayerID
|
||||
public static class PlayerId
|
||||
{
|
||||
public const string LXMusic = "cn.toside.music.desktop";
|
||||
public const string LXMusicPortable = "lx-music-desktop.exe";
|
||||
@@ -25,5 +25,6 @@
|
||||
public const string MoeKoeMusic = "cn.MoeKoe.Music";
|
||||
public const string MoeKoeMusicAlternative = "electron.app.MoeKoe Music";
|
||||
public const string Listen1 = "com.listen1.listen1";
|
||||
public const string OriginalSoundHQPlayer = "SennpaiStudio.528762A6196EF_z79ft30j24epr!App";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,6 @@
|
||||
public const string SaltPlayerForWindowsSteam = "Salt Player for Windows (Steam)";
|
||||
public const string MoeKoeMusic = "MoeKoe Music";
|
||||
public const string Listen1 = "Listen 1";
|
||||
public const string OriginalSoundHQPlayer = "Original Sound HQ Player";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ namespace BetterLyrics.WinUI3.Constants
|
||||
{
|
||||
public static readonly TimeSpan DebounceTimeout = TimeSpan.FromMilliseconds(250);
|
||||
public static readonly TimeSpan AnimationDuration = TimeSpan.FromMilliseconds(350);
|
||||
public static readonly TimeSpan WaitingDuration = TimeSpan.FromMilliseconds(300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +57,12 @@
|
||||
|
||||
<dev:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<StackPanel Margin="-12,0,0,0" Orientation="Horizontal">
|
||||
<dev:WrapPanel Margin="-12,0,0,0" Orientation="Horizontal">
|
||||
<HyperlinkButton Content="GitHub" NavigateUri="{x:Bind const:Link.BetterLyricsGitHub}" />
|
||||
<HyperlinkButton x:Uid="UserGuide" NavigateUri="{x:Bind const:Link.UserGuide}" />
|
||||
<HyperlinkButton x:Uid="PrivacyPolicy" NavigateUri="{x:Bind const:Link.PrivacyPolicy}" />
|
||||
<HyperlinkButton x:Uid="TermsOfService" NavigateUri="{x:Bind const:Link.TermsOfService}" />
|
||||
</StackPanel>
|
||||
</dev:WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
</dev:SettingsCard>
|
||||
@@ -70,18 +70,18 @@
|
||||
<dev:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Uid="SetingsPageFeedback" />
|
||||
<StackPanel Margin="-12,0,0,0" Orientation="Horizontal">
|
||||
<dev:WrapPanel Margin="-12,0,0,0" Orientation="Horizontal">
|
||||
<HyperlinkButton Content="QQ 反馈交流群" NavigateUri="{x:Bind const:Link.QQGroup}" />
|
||||
<HyperlinkButton Content="Discord" NavigateUri="{x:Bind const:Link.Discord}" />
|
||||
<HyperlinkButton Content="Telegram" NavigateUri="{x:Bind const:Link.Telegram}" />
|
||||
</StackPanel>
|
||||
</dev:WrapPanel>
|
||||
</StackPanel>
|
||||
</dev:SettingsCard>
|
||||
|
||||
<dev:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Uid="SetingsPageDonation" />
|
||||
<StackPanel Margin="-12,0,0,0" Orientation="Horizontal">
|
||||
<dev:WrapPanel Margin="-12,0,0,0" Orientation="Horizontal">
|
||||
<HyperlinkButton Content="Buy Me a Coffee" NavigateUri="{x:Bind const:Link.BuyMeACoffee}" />
|
||||
<HyperlinkButton Content="PayPal" NavigateUri="{x:Bind const:Link.PayPal}" />
|
||||
<HyperlinkButton
|
||||
@@ -117,26 +117,25 @@
|
||||
</HyperlinkButton.ContextFlyout>
|
||||
</HyperlinkButton>
|
||||
<HyperlinkButton Content="爱发电" NavigateUri="{x:Bind const:Link.Afdian}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="*" />
|
||||
</dev:WrapPanel>
|
||||
<Grid ColumnSpacing="6">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="*" />
|
||||
<TextBlock
|
||||
x:Uid="SetingsPageThanks"
|
||||
Grid.Column="1"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</dev:SettingsCard>
|
||||
|
||||
<dev:SettingsCard x:Uid="SettingsPageThanksList">
|
||||
<Button
|
||||
Click="Patron_Click"
|
||||
Content="{ui:FontIcon FontSize=16,
|
||||
FontFamily={StaticResource IconFontFamily},
|
||||
Glyph=}"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
</dev:SettingsCard>
|
||||
|
||||
</dev:SettingsExpander.Items>
|
||||
<dev:SettingsExpander.ItemsFooter>
|
||||
<InfoBar
|
||||
@@ -149,6 +148,96 @@
|
||||
</dev:SettingsExpander.ItemsFooter>
|
||||
</dev:SettingsExpander>
|
||||
|
||||
<dev:SettingsExpander x:Uid="SettingsPageThanksList">
|
||||
<dev:SettingsExpander.HeaderIcon>
|
||||
<ImageIcon Source="ms-appx:///Assets/RevolvingHearts.gif" />
|
||||
</dev:SettingsExpander.HeaderIcon>
|
||||
<dev:SettingsExpander.Items>
|
||||
|
||||
<!-- 贡献者 -->
|
||||
<dev:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<RichTextBlock>
|
||||
<Paragraph>
|
||||
<Run x:Uid="SetingsPageContributors" />
|
||||
<Run Text="(Code)" />
|
||||
</Paragraph>
|
||||
</RichTextBlock>
|
||||
<dev:WrapPanel Margin="-12,0,0,0" Orientation="Horizontal">
|
||||
<HyperlinkButton Content="jayfunc" NavigateUri="https://github.com/jayfunc" />
|
||||
<HyperlinkButton Content="Raspberry-Monster" NavigateUri="https://github.com/Raspberry-Monster" />
|
||||
<HyperlinkButton Content="ZHider" NavigateUri="https://github.com/ZHider" />
|
||||
<HyperlinkButton Content="kusutori" NavigateUri="https://github.com/kusutori" />
|
||||
</dev:WrapPanel>
|
||||
</StackPanel>
|
||||
</dev:SettingsCard>
|
||||
|
||||
<!-- 贡献者 (Translator) -->
|
||||
<dev:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<RichTextBlock>
|
||||
<Paragraph>
|
||||
<Run x:Uid="SetingsPageContributors" />
|
||||
<Run Text="(Translator)" />
|
||||
</Paragraph>
|
||||
</RichTextBlock>
|
||||
<dev:WrapPanel Margin="-12,0,0,0" Orientation="Horizontal">
|
||||
<HyperlinkButton Content="borcolasky" NavigateUri="https://crowdin.com/profile/borcolasky" />
|
||||
<HyperlinkButton Content="SuHeAndZl" NavigateUri="https://crowdin.com/profile/SuHeAndZl" />
|
||||
</dev:WrapPanel>
|
||||
</StackPanel>
|
||||
</dev:SettingsCard>
|
||||
|
||||
<!-- 赞助 -->
|
||||
<dev:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Uid="SettingsPagePatrons" />
|
||||
<dev:WrapPanel Margin="-12,0,0,0" Orientation="Horizontal">
|
||||
<uc:PatronControl Date="Dec 3, 2025" PatronName="YE" />
|
||||
<uc:PatronControl Date="Nov 23, 2025" PatronName="**玄" />
|
||||
<uc:PatronControl Date="Nov 21, 2025" PatronName="**智" />
|
||||
<uc:PatronControl Date="Nov 17, 2025" PatronName="*鹤" />
|
||||
<uc:PatronControl Date="Nov 2, 2025" PatronName="借过" />
|
||||
<uc:PatronControl Date="Aug 28, 2025" PatronName="**华" />
|
||||
<TextBlock x:Uid="SettingsPageUserWhoPurchased" Margin="12,8" />
|
||||
</dev:WrapPanel>
|
||||
</StackPanel>
|
||||
</dev:SettingsCard>
|
||||
|
||||
<!-- 特别鸣谢 -->
|
||||
<dev:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Uid="SetingsPageSpecialThanks" />
|
||||
<TextBlock x:Uid="SettingsPageYouNowUsing" Margin="0,8" />
|
||||
</StackPanel>
|
||||
</dev:SettingsCard>
|
||||
|
||||
<!-- 代码参考 -->
|
||||
<dev:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Uid="SetingsPageDeps" />
|
||||
<HyperlinkButton Margin="-12,0,0,0" NavigateUri="https://github.com/jayfunc/BetterLyrics/network/dependencies">
|
||||
<TextBlock x:Uid="SetingsPageDeps" />
|
||||
</HyperlinkButton>
|
||||
</StackPanel>
|
||||
</dev:SettingsCard>
|
||||
|
||||
<!-- UI/UX 参考 -->
|
||||
<dev:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Uid="SetingsPageUIUXRef" />
|
||||
<dev:WrapPanel Margin="-12,0,0,0" Orientation="Horizontal">
|
||||
<HyperlinkButton Content="refined-now-playing-netease" NavigateUri="https://github.com/solstice23/refined-now-playing-netease" />
|
||||
<HyperlinkButton Content="Lyricify" NavigateUri="https://github.com/WXRIW/Lyricify-App" />
|
||||
<HyperlinkButton Content="椒盐音乐 Salt Player" NavigateUri="https://moriafly.com/program/salt-player" />
|
||||
<HyperlinkButton Content="MyToolBar" NavigateUri="https://github.com/TwilightLemon/MyToolBar" />
|
||||
</dev:WrapPanel>
|
||||
</StackPanel>
|
||||
</dev:SettingsCard>
|
||||
|
||||
</dev:SettingsExpander.Items>
|
||||
</dev:SettingsExpander>
|
||||
|
||||
<dev:SettingsCard x:Uid="SettingsPageMockMusicPlaying">
|
||||
<HyperlinkButton x:Uid="SettingsPagePlayingMockMusicButton" NavigateUri="https://soundcloud.com/carlyraejepsen/cut-to-the-feeling" />
|
||||
</dev:SettingsCard>
|
||||
@@ -182,6 +271,12 @@
|
||||
</dev:SettingsExpander.ItemsHeader>
|
||||
</dev:SettingsExpander>
|
||||
|
||||
<dev:SettingsCard x:Uid="SettingsPageSettingsPlayHistory" Visibility="Collapsed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button x:Uid="SettingsPageExportPlayHistoryButton" Command="{x:Bind ViewModel.ExportPlayHistoryCommand}" />
|
||||
</StackPanel>
|
||||
</dev:SettingsCard>
|
||||
|
||||
<dev:SettingsCard x:Uid="SettingsPageFixedTimeStep" Visibility="Collapsed">
|
||||
<ToggleSwitch IsOn="{x:Bind ViewModel.AppSettings.AdvancedSettings.IsFixedTimeStep, Mode=TwoWay}" />
|
||||
</dev:SettingsCard>
|
||||
@@ -195,194 +290,28 @@
|
||||
Value="{x:Bind ViewModel.AppSettings.AdvancedSettings.FPS, Mode=TwoWay}" />
|
||||
</dev:SettingsCard>
|
||||
|
||||
<RichTextBlock
|
||||
Margin="0,16,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
HorizontalTextAlignment="Center"
|
||||
LineHeight="28">
|
||||
<Paragraph FontWeight="Bold">
|
||||
<Run Text="{x:Bind const:App.AppName}" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run Text="An elegant and deeply customizable lyrics visualizer & versatile music player" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run Text="Proudly built by" />
|
||||
<Hyperlink NavigateUri="{x:Bind const:Link.AuthorGitHub}">
|
||||
<Run Text="{x:Bind const:App.AppAuthor}" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
</RichTextBlock>
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
<Grid
|
||||
x:Name="CreditsReel"
|
||||
Background="{ThemeResource AcrylicInAppFillColorDefaultBrush}"
|
||||
Opacity="0"
|
||||
SizeChanged="CreditsReel_SizeChanged"
|
||||
Tapped="CreditsReel_Tapped"
|
||||
Visibility="Collapsed">
|
||||
<Grid.OpacityTransition>
|
||||
<ScalarTransition />
|
||||
</Grid.OpacityTransition>
|
||||
<ScrollViewer
|
||||
x:Name="CreditsReelScrollViewer"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Hidden"
|
||||
ScrollViewer.VerticalScrollMode="Disabled">
|
||||
<RichTextBlock
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalTextAlignment="Center"
|
||||
LineHeight="28"
|
||||
PointerEntered="RichTextBlock_PointerEntered"
|
||||
PointerExited="RichTextBlock_PointerExited">
|
||||
|
||||
<Paragraph x:Name="CreditsReelHeader" />
|
||||
|
||||
<!-- 贡献者 -->
|
||||
<Paragraph Margin="0,20,0,0" FontWeight="Bold">
|
||||
<Run x:Uid="SetingsPageContributors" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/jayfunc">
|
||||
<Run Text="jayfunc" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/Raspberry-Monster">
|
||||
<Run Text="Raspberry-Monster" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/ZHider">
|
||||
<Run Text="ZHider" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/kusutori">
|
||||
<Run Text="kusutori" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
|
||||
<!-- 赞助 -->
|
||||
<Paragraph Margin="0,20,0,0" FontWeight="Bold">
|
||||
<Run x:Uid="SettingsPagePatrons" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run Text="YE" />
|
||||
<Run Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Dec 3, 2025" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run Text="**玄" />
|
||||
<Run Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Nov 23, 2025" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run Text="**智" />
|
||||
<Run Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Nov 21, 2025" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run Text="*鹤" />
|
||||
<Run Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Nov 17, 2025" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run Text="借过" />
|
||||
<Run Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Nov 2, 2025" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run Text="**华" />
|
||||
<Run Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="Aug 28, 2025" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run x:Uid="SettingsPageUserWhoPurchased" />
|
||||
</Paragraph>
|
||||
|
||||
<!-- 特别鸣谢 -->
|
||||
<Paragraph Margin="0,20,0,0" FontWeight="Bold">
|
||||
<Run x:Uid="SetingsPageSpecialThanks" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run x:Uid="SettingsPageYouNowUsing" />
|
||||
</Paragraph>
|
||||
|
||||
<!-- 代码参考 -->
|
||||
<Paragraph Margin="0,20,0,0" FontWeight="Bold">
|
||||
<Run x:Uid="SetingsPageDeps" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://gist.github.com/mcworkaholic/82fbf203e3f1043bbe534b5b2974c0ce">
|
||||
<Run Text="Get album artwork from ITunes (with Python3 or C#)" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://stackoverflow.com/a/32013610/11048731">
|
||||
<Run Text="FullyObservableCollection" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/Storyteller-Studios/Impressionist">
|
||||
<Run Text="Impressionist" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/Storyteller-Studios/ColorThief.WinUI3">
|
||||
<Run Text="ColorThief.WinUI3" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/Johnwikix/SpectrumVisualization">
|
||||
<Run Text="SpectrumVisualization" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://www.shadertoy.com/view/Mdt3Df">
|
||||
<Run Text="Snow (as shown in sweden)" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://www.shadertoy.com/view/lllSR2">
|
||||
<Run Text="w10" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/mo-jinran/Taskbar-Lyrics">
|
||||
<Run Text="Taskbar-Lyrics" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/jayfunc/BetterLyrics/network/dependencies">
|
||||
<Run Text="..." />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
|
||||
<!-- UI/UX 设计参考 -->
|
||||
<Paragraph Margin="0,20,0,0" FontWeight="Bold">
|
||||
<Run x:Uid="SetingsPageUIUXRef" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/solstice23/refined-now-playing-netease">
|
||||
<Run Text="refined-now-playing-netease" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/WXRIW/Lyricify-App">
|
||||
<Run Text="Lyricify" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://moriafly.com/program/salt-player">
|
||||
<Run Text="椒盐音乐 Salt Player" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="https://github.com/TwilightLemon/MyToolBar">
|
||||
<Run Text="MyToolBar" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Hyperlink NavigateUri="">
|
||||
<Run Text="" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph Margin="0,20,0,0" FontWeight="Bold">
|
||||
<Run Text="{x:Bind const:App.AppName}" />
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<Run Text="Proudly built by" />
|
||||
<Hyperlink NavigateUri="{x:Bind const:Link.AuthorGitHub}">
|
||||
<Run Text="{x:Bind const:App.AppAuthor}" />
|
||||
</Hyperlink>
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph x:Name="CreditsReelFooter" />
|
||||
|
||||
</RichTextBlock>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -11,7 +11,6 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
{
|
||||
public sealed partial class AboutControl : UserControl
|
||||
{
|
||||
private bool _isCreditsScrolling = false;
|
||||
public AboutControlViewModel ViewModel => (AboutControlViewModel)DataContext;
|
||||
|
||||
public AboutControl()
|
||||
@@ -20,47 +19,6 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
DataContext = Ioc.Default.GetRequiredService<AboutControlViewModel>();
|
||||
}
|
||||
|
||||
private async void Patron_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||
{
|
||||
CompositionTarget.Rendering += CompositionTarget_Rendering;
|
||||
CreditsReel.Visibility = Microsoft.UI.Xaml.Visibility.Visible;
|
||||
CreditsReel.Opacity = 1;
|
||||
_isCreditsScrolling = true;
|
||||
}
|
||||
|
||||
private void CompositionTarget_Rendering(object? sender, object e)
|
||||
{
|
||||
if (_isCreditsScrolling)
|
||||
{
|
||||
CreditsReelScrollViewer.ChangeView(null, CreditsReelScrollViewer.VerticalOffset + 0.5, null);
|
||||
}
|
||||
}
|
||||
|
||||
private async void CreditsReel_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e)
|
||||
{
|
||||
CreditsReel.Opacity = 0;
|
||||
await Task.Delay(Constants.Time.AnimationDuration);
|
||||
CreditsReel.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed;
|
||||
CompositionTarget.Rendering -= CompositionTarget_Rendering;
|
||||
CreditsReelScrollViewer.ChangeView(null, 0, null);
|
||||
}
|
||||
|
||||
private void CreditsReel_SizeChanged(object sender, Microsoft.UI.Xaml.SizeChangedEventArgs e)
|
||||
{
|
||||
CreditsReelHeader.LineHeight = e.NewSize.Height;
|
||||
CreditsReelFooter.LineHeight = e.NewSize.Height / 2;
|
||||
}
|
||||
|
||||
private void RichTextBlock_PointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
|
||||
{
|
||||
_isCreditsScrolling = false;
|
||||
}
|
||||
|
||||
private void RichTextBlock_PointerExited(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
|
||||
{
|
||||
_isCreditsScrolling = true;
|
||||
}
|
||||
|
||||
private void WeChat_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||
{
|
||||
WeChatFlyout.ShowAt(WeChatButton);
|
||||
|
||||
@@ -8,7 +8,7 @@ using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Models.Settings;
|
||||
using BetterLyrics.WinUI3.Renderer;
|
||||
using BetterLyrics.WinUI3.Services.LastFMService;
|
||||
using BetterLyrics.WinUI3.Services.MediaSessionsService;
|
||||
using BetterLyrics.WinUI3.Services.GSMTCService;
|
||||
using BetterLyrics.WinUI3.Services.SettingsService;
|
||||
using CommunityToolkit.Mvvm.DependencyInjection;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
@@ -31,7 +31,7 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
public sealed partial class LyricsCanvas : UserControl,
|
||||
IRecipient<PropertyChangedMessage<TimeSpan>>,
|
||||
IRecipient<PropertyChangedMessage<LyricsData?>>,
|
||||
IRecipient<PropertyChangedMessage<SongInfo?>>,
|
||||
IRecipient<PropertyChangedMessage<SongInfo>>,
|
||||
IRecipient<PropertyChangedMessage<int>>,
|
||||
IRecipient<PropertyChangedMessage<double>>,
|
||||
IRecipient<PropertyChangedMessage<bool>>,
|
||||
@@ -41,8 +41,7 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
IRecipient<PropertyChangedMessage<IRandomAccessStream?>>
|
||||
{
|
||||
private readonly ISettingsService _settingsService = Ioc.Default.GetRequiredService<ISettingsService>();
|
||||
private readonly IMediaSessionsService _mediaSessionsService = Ioc.Default.GetRequiredService<IMediaSessionsService>();
|
||||
private readonly ILastFMService _lastFMService = Ioc.Default.GetRequiredService<ILastFMService>();
|
||||
private readonly IGSMTCService _gsmtcService = Ioc.Default.GetRequiredService<IGSMTCService>();
|
||||
|
||||
private readonly LyricsRenderer _lyricsRenderer = new();
|
||||
private readonly FluidBackgroundRenderer _fluidRenderer = new();
|
||||
@@ -99,8 +98,6 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
|
||||
private TimeSpan _songPositionWithOffset;
|
||||
private TimeSpan _songPosition; // <20><>ǰ<EFBFBD><C7B0><EFBFBD><EFBFBD>ʱ<EFBFBD><CAB1>
|
||||
private TimeSpan _totalPlayedTime; // <20><>ǰ<EFBFBD><C7B0><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ػ<EFBFBD><D8BB>ظ<EFBFBD><D8B8><EFBFBD><EFBFBD>ŵ<EFBFBD>ʱ<EFBFBD>䣩
|
||||
private bool _isLastFMTracked = false;
|
||||
|
||||
private double _renderLyricsStartX = 0;
|
||||
private double _renderLyricsStartY = 0;
|
||||
@@ -346,7 +343,7 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
var lyricsStyle = _lyricsWindowStatus.LyricsStyleSettings;
|
||||
var lyricsEffect = _lyricsWindowStatus.LyricsEffectSettings;
|
||||
|
||||
double songDuration = _mediaSessionsService.CurrentSongInfo?.DurationMs ?? 0;
|
||||
double songDuration = _gsmtcService.CurrentSongInfo.DurationMs;
|
||||
bool isForceWordByWord = _settingsService.AppSettings.GeneralSettings.IsForceWordByWordEffect;
|
||||
|
||||
Color overlayColor;
|
||||
@@ -460,7 +457,7 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
var lyricsBg = _lyricsWindowStatus.LyricsBackgroundSettings;
|
||||
var lyricsStyle = _lyricsWindowStatus.LyricsStyleSettings;
|
||||
var lyricsEffect = _lyricsWindowStatus.LyricsEffectSettings;
|
||||
var lyricsData = _mediaSessionsService.CurrentLyricsData;
|
||||
var lyricsData = _gsmtcService.CurrentLyricsData;
|
||||
|
||||
TimeSpan elapsedTime = args.Timing.ElapsedTime;
|
||||
|
||||
@@ -655,41 +652,22 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
|
||||
private void UpdatePlaybackState(TimeSpan elapsedTime)
|
||||
{
|
||||
if (_mediaSessionsService.CurrentIsPlaying)
|
||||
if (_gsmtcService.CurrentIsPlaying)
|
||||
{
|
||||
_songPosition += elapsedTime;
|
||||
_totalPlayedTime += elapsedTime;
|
||||
_songPositionWithOffset = _songPosition + TimeSpan.FromMilliseconds(_mediaSessionsService.CurrentMediaSourceProviderInfo?.PositionOffset ?? 0);
|
||||
CheckAndScrobbleLastFM();
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckAndScrobbleLastFM()
|
||||
{
|
||||
bool isEnabled = _mediaSessionsService.CurrentMediaSourceProviderInfo?.IsLastFMTrackEnabled ?? false;
|
||||
if (!isEnabled || _isLastFMTracked) return;
|
||||
|
||||
var songInfo = _mediaSessionsService.CurrentSongInfo;
|
||||
if (songInfo == null || songInfo.Duration <= 0) return;
|
||||
|
||||
if (_totalPlayedTime.TotalSeconds >= songInfo.Duration * 0.5)
|
||||
{
|
||||
_isLastFMTracked = true;
|
||||
_lastFMService.TrackAsync(songInfo);
|
||||
_songPositionWithOffset = _songPosition + TimeSpan.FromMilliseconds(_gsmtcService.CurrentMediaSourceProviderInfo?.PositionOffset ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetPlaybackState()
|
||||
{
|
||||
_songPosition = TimeSpan.Zero;
|
||||
_totalPlayedTime = TimeSpan.Zero;
|
||||
_isLastFMTracked = false;
|
||||
}
|
||||
|
||||
private void UpdateRenderLyricsLines()
|
||||
{
|
||||
_renderLyricsLines = null;
|
||||
_renderLyricsLines = _mediaSessionsService.CurrentLyricsData?.LyricsLines.Select(x => new RenderLyricsLine()
|
||||
_renderLyricsLines = _gsmtcService.CurrentLyricsData?.LyricsLines.Select(x => new RenderLyricsLine()
|
||||
{
|
||||
LyricsSyllables = x.LyricsSyllables,
|
||||
StartMs = x.StartMs,
|
||||
@@ -702,7 +680,7 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
|
||||
private async Task ReloadCoverBackgroundResourcesAsync()
|
||||
{
|
||||
if (_mediaSessionsService.AlbumArtBitmapStream is IRandomAccessStream stream)
|
||||
if (_gsmtcService.AlbumArtBitmapStream is IRandomAccessStream stream)
|
||||
{
|
||||
stream.Seek(0);
|
||||
CanvasBitmap bitmap = await CanvasBitmap.LoadAsync(Canvas, stream);
|
||||
@@ -712,26 +690,19 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
|
||||
public void Receive(PropertyChangedMessage<TimeSpan> message)
|
||||
{
|
||||
if (message.Sender is IMediaSessionsService)
|
||||
if (message.Sender is IGSMTCService)
|
||||
{
|
||||
if (message.PropertyName == nameof(IMediaSessionsService.CurrentPosition))
|
||||
if (message.PropertyName == nameof(IGSMTCService.CurrentPosition))
|
||||
{
|
||||
var realPosition = message.NewValue;
|
||||
|
||||
var diff = Math.Abs(_songPosition.TotalMilliseconds - realPosition.TotalMilliseconds);
|
||||
var timelineSyncThreshold = _mediaSessionsService.CurrentMediaSourceProviderInfo?.TimelineSyncThreshold ?? 0;
|
||||
var timelineSyncThreshold = _gsmtcService.CurrentMediaSourceProviderInfo?.TimelineSyncThreshold ?? 0;
|
||||
|
||||
// ƫ<><C6AB> or seek
|
||||
if (diff >= timelineSyncThreshold)
|
||||
{
|
||||
_songPosition = realPosition;
|
||||
|
||||
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˿<EFBFBD>ͷ<EFBFBD><CDB7><EFBFBD><EFBFBD><EFBFBD><EFBFBD> LastFM ͳ<><CDB3>״̬
|
||||
if (_songPosition.TotalSeconds <= 1)
|
||||
{
|
||||
_totalPlayedTime = TimeSpan.Zero;
|
||||
_isLastFMTracked = false;
|
||||
}
|
||||
}
|
||||
|
||||
// <20>϶<EFBFBD><CFB6><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ȴ<EFBFBD><C8B4><EFBFBD><EFBFBD><EFBFBD>
|
||||
@@ -745,9 +716,9 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
|
||||
public void Receive(PropertyChangedMessage<LyricsData?> message)
|
||||
{
|
||||
if (message.Sender is IMediaSessionsService)
|
||||
if (message.Sender is IGSMTCService)
|
||||
{
|
||||
if (message.PropertyName == nameof(IMediaSessionsService.CurrentLyricsData))
|
||||
if (message.PropertyName == nameof(IGSMTCService.CurrentLyricsData))
|
||||
{
|
||||
UpdateRenderLyricsLines();
|
||||
_isLayoutChanged = true;
|
||||
@@ -755,11 +726,11 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
}
|
||||
}
|
||||
|
||||
public void Receive(PropertyChangedMessage<SongInfo?> message)
|
||||
public void Receive(PropertyChangedMessage<SongInfo> message)
|
||||
{
|
||||
if (message.Sender is IMediaSessionsService)
|
||||
if (message.Sender is IGSMTCService)
|
||||
{
|
||||
if (message.PropertyName == nameof(IMediaSessionsService.CurrentSongInfo))
|
||||
if (message.PropertyName == nameof(IGSMTCService.CurrentSongInfo))
|
||||
{
|
||||
ResetPlaybackState();
|
||||
}
|
||||
@@ -912,13 +883,14 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
|
||||
public void Receive(PropertyChangedMessage<IRandomAccessStream?> message)
|
||||
{
|
||||
if (message.Sender is IMediaSessionsService)
|
||||
if (message.Sender is IGSMTCService)
|
||||
{
|
||||
if (message.PropertyName == nameof(IMediaSessionsService.AlbumArtBitmapStream))
|
||||
if (message.PropertyName == nameof(IGSMTCService.AlbumArtBitmapStream))
|
||||
{
|
||||
_ = ReloadCoverBackgroundResourcesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid Grid.Column="0">
|
||||
<ScrollViewer>
|
||||
<ScrollViewer Padding="8,0">
|
||||
<StackPanel Spacing="{StaticResource SettingsCardSpacing}">
|
||||
|
||||
<TextBlock x:Uid="LyricsSearchControlSongInfoMapping" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||
@@ -153,12 +153,11 @@
|
||||
<CheckBox x:Uid="LyricsSearchControlMarkAsPureMusic" IsChecked="{x:Bind ViewModel.MappedSongSearchQuery.IsMarkedAsPureMusic, Mode=TwoWay}" />
|
||||
</dev:SettingsCard>
|
||||
|
||||
<dev:SettingsCard x:Uid="LyricsSearchControlTargetSearchProvider">
|
||||
<Button
|
||||
x:Uid="LyricsSearchControlSearch"
|
||||
Command="{x:Bind ViewModel.SearchCommand}"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
</dev:SettingsCard>
|
||||
<Button
|
||||
x:Uid="LyricsSearchControlSearch"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{x:Bind ViewModel.SearchCommand}"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
|
||||
<dev:SettingsCard x:Uid="LyricsSearchControlIgnoreCache">
|
||||
<ToggleSwitch IsOn="{x:Bind ViewModel.AppSettings.GeneralSettings.IgnoreCacheWhenSearching, Mode=TwoWay}" />
|
||||
@@ -183,10 +182,7 @@
|
||||
<local:PropertyRow x:Uid="SettingsPageSongTitle" Value="{x:Bind Title, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageArtist" Value="{x:Bind DisplayArtists, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageAlbum" Value="{x:Bind Album, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow
|
||||
x:Uid="LyricsSearchControlDurauion"
|
||||
Unit="s"
|
||||
Value="{x:Bind Duration, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="LyricsSearchControlDurauion" Value="{x:Bind Duration, Converter={StaticResource SecondsToFormattedTimeConverter}, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow
|
||||
x:Uid="LyricsPageMatchPercentage"
|
||||
Unit="%"
|
||||
@@ -244,8 +240,6 @@
|
||||
<ProgressBar
|
||||
VerticalAlignment="Top"
|
||||
IsIndeterminate="True"
|
||||
ShowError="False"
|
||||
ShowPaused="False"
|
||||
Visibility="{x:Bind ViewModel.IsSearching, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" />
|
||||
</Grid>
|
||||
<Grid Grid.Column="2">
|
||||
@@ -346,8 +340,8 @@
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" ColumnSpacing="6">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
@@ -187,29 +187,51 @@
|
||||
|
||||
<controls:Segmented
|
||||
x:Name="ConfigSegmented"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="ConfigSegmented_SelectionChanged"
|
||||
Style="{StaticResource PivotSegmentedStyle}">
|
||||
|
||||
<controls:SegmentedItem x:Name="WindowSegmentedItem" Tag="Window">
|
||||
<TextBlock x:Uid="AppSettingsControlGeneral" />
|
||||
<TextBlock
|
||||
x:Uid="AppSettingsControlGeneral"
|
||||
MaxWidth="120"
|
||||
TextWrapping="Wrap" />
|
||||
</controls:SegmentedItem>
|
||||
<controls:SegmentedItem x:Name="LayoutSegmentedItem" Tag="Layout">
|
||||
<TextBlock x:Uid="SettingsPageLayout" />
|
||||
<TextBlock
|
||||
x:Uid="SettingsPageLayout"
|
||||
MaxWidth="120"
|
||||
TextWrapping="Wrap" />
|
||||
</controls:SegmentedItem>
|
||||
<controls:SegmentedItem x:Name="AlbumArtStyleSegmentedItem" Tag="AlbumArtStyle">
|
||||
<TextBlock x:Uid="SettingsPageAlbumStyle" />
|
||||
<TextBlock
|
||||
x:Uid="SettingsPageAlbumStyle"
|
||||
MaxWidth="120"
|
||||
TextWrapping="Wrap" />
|
||||
</controls:SegmentedItem>
|
||||
<controls:SegmentedItem Tag="AlbumArtEffect">
|
||||
<TextBlock x:Uid="SettingsPageAlbumEffect" />
|
||||
<TextBlock
|
||||
x:Uid="SettingsPageAlbumEffect"
|
||||
MaxWidth="120"
|
||||
TextWrapping="Wrap" />
|
||||
</controls:SegmentedItem>
|
||||
<controls:SegmentedItem Tag="LyricsStyle">
|
||||
<TextBlock x:Uid="SettingsPageLyricsStyle" />
|
||||
<TextBlock
|
||||
x:Uid="SettingsPageLyricsStyle"
|
||||
MaxWidth="120"
|
||||
TextWrapping="Wrap" />
|
||||
</controls:SegmentedItem>
|
||||
<controls:SegmentedItem Tag="LyricsEffect">
|
||||
<TextBlock x:Uid="SettingsPageLyricsEffect" />
|
||||
<TextBlock
|
||||
x:Uid="SettingsPageLyricsEffect"
|
||||
MaxWidth="120"
|
||||
TextWrapping="Wrap" />
|
||||
</controls:SegmentedItem>
|
||||
<controls:SegmentedItem Tag="LyricsBackground">
|
||||
<TextBlock x:Uid="SettingsPageBackgroundOverlay" />
|
||||
<TextBlock
|
||||
x:Uid="SettingsPageBackgroundOverlay"
|
||||
MaxWidth="120"
|
||||
TextWrapping="Wrap" />
|
||||
</controls:SegmentedItem>
|
||||
|
||||
</controls:Segmented>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:dev="using:DevWinUI"
|
||||
xmlns:enums="using:BetterLyrics.WinUI3.Enums"
|
||||
xmlns:interactivity="using:Microsoft.Xaml.Interactivity"
|
||||
xmlns:local="using:BetterLyrics.WinUI3.Controls"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
@@ -50,79 +51,123 @@
|
||||
SelectionMode="None">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:MediaFolder">
|
||||
<dev:SettingsExpander Description="{x:Bind ConnectionSummary, Mode=OneWay}">
|
||||
<dev:SettingsExpander IsExpanded="True">
|
||||
|
||||
<dev:SettingsExpander.HeaderIcon>
|
||||
<FontIcon FontFamily="{StaticResource IconFontFamily}" Glyph="{x:Bind SourceType, Converter={StaticResource FileSourceTypeToIconConverter}, Mode=OneWay}" />
|
||||
</dev:SettingsExpander.HeaderIcon>
|
||||
|
||||
<dev:SettingsExpander.Header>
|
||||
<HyperlinkButton
|
||||
Padding="0"
|
||||
Click="LocalFolderHyperlinkButton_Click"
|
||||
Content="{x:Bind Path, Mode=OneWay}"
|
||||
Tag="{x:Bind Path, Mode=OneWay}"
|
||||
ToolTipService.ToolTip="{x:Bind ConnectionSummary}" />
|
||||
<TextBlock IsTextSelectionEnabled="True" Text="{x:Bind Name, Mode=OneWay}" />
|
||||
</dev:SettingsExpander.Header>
|
||||
<dev:SettingsExpander.Description>
|
||||
<TextBlock IsTextSelectionEnabled="True" Text="{x:Bind ConnectionSummary, Mode=OneWay}" />
|
||||
</dev:SettingsExpander.Description>
|
||||
|
||||
<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 x:Uid="MediaSettingsControlNameSetting">
|
||||
<TextBox VerticalAlignment="Center" Text="{x:Bind Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||
</dev:SettingsCard>
|
||||
|
||||
<dev:SettingsCard x:Uid="SettingsPageMusicLibRealTimeWatch" IsEnabled="{Binding IsLocal, Mode=OneWay}">
|
||||
<ToggleSwitch IsOn="{Binding IsRealTimeWatchEnabled, Mode=TwoWay}" />
|
||||
<dev:SettingsCard x:Uid="MediaSettingsControlLastSyncTime" Description="{x:Bind LastSyncTime.ToString(), Mode=OneWay, TargetNullValue=N/A}">
|
||||
<Button
|
||||
x:Uid="MediaSettingsControlSyncNow"
|
||||
Click="SyncNowButton_Click"
|
||||
IsEnabled="{x:Bind IsEnabled, Mode=OneWay}" />
|
||||
</dev:SettingsCard>
|
||||
<dev:SettingsCard x:Uid="MusicSettingsControlAutoSyncInterval">
|
||||
<ComboBox IsEnabled="{x:Bind IsEnabled, Mode=OneWay}" SelectedIndex="{x:Bind ScanInterval, Mode=TwoWay, Converter={StaticResource EnumToIntConverter}}">
|
||||
<ComboBoxItem x:Uid="MusicSettingsControlAutoSyncIntervalDisabled" />
|
||||
<ComboBoxItem x:Uid="MusicSettingsControlAutoSyncIntervalEveryFifteenMin" />
|
||||
<ComboBoxItem x:Uid="MusicSettingsControlAutoSyncIntervalEveryHour" />
|
||||
<ComboBoxItem x:Uid="MusicSettingsControlAutoSyncIntervalEverySixHrs" />
|
||||
<ComboBoxItem x:Uid="MusicSettingsControlAutoSyncIntervalEveryDay" />
|
||||
</ComboBox>
|
||||
</dev:SettingsCard>
|
||||
<dev:SettingsCard>
|
||||
<Button x:Uid="SettingsPageRemovePath" Click="SettingsPageRemovePathButton_Click" />
|
||||
</dev:SettingsCard>
|
||||
</dev:SettingsExpander.Items>
|
||||
|
||||
<dev:SettingsExpander.ItemsHeader>
|
||||
<StackPanel>
|
||||
<!-- Index info -->
|
||||
<InfoBar
|
||||
IsClosable="False"
|
||||
IsOpen="True"
|
||||
Message="{x:Bind StatusText, Mode=OneWay}"
|
||||
Severity="{x:Bind StatusSeverity, Mode=OneWay}" />
|
||||
<ProgressBar
|
||||
Background="Transparent"
|
||||
Visibility="{x:Bind IsProcessing, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"
|
||||
Value="{x:Bind IndexingProgress, Mode=OneWay}">
|
||||
<interactivity:Interaction.Behaviors>
|
||||
<interactivity:DataTriggerBehavior
|
||||
Binding="{x:Bind IndexingProgress, Mode=OneWay}"
|
||||
ComparisonCondition="Equal"
|
||||
Value="0">
|
||||
<interactivity:ChangePropertyAction PropertyName="IsIndeterminate" Value="True" />
|
||||
</interactivity:DataTriggerBehavior>
|
||||
<interactivity:DataTriggerBehavior
|
||||
Binding="{x:Bind IndexingProgress, Mode=OneWay}"
|
||||
ComparisonCondition="NotEqual"
|
||||
Value="0">
|
||||
<interactivity:ChangePropertyAction PropertyName="IsIndeterminate" Value="False" />
|
||||
</interactivity:DataTriggerBehavior>
|
||||
</interactivity:Interaction.Behaviors>
|
||||
</ProgressBar>
|
||||
</StackPanel>
|
||||
</dev:SettingsExpander.ItemsHeader>
|
||||
|
||||
</dev:SettingsExpander>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
|
||||
<dev:SettingsCard Style="{StaticResource DefaultSettingsExpanderItemStyle}">
|
||||
<StackPanel
|
||||
Margin="0,6,0,0"
|
||||
HorizontalAlignment="Right"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<Button Command="{x:Bind ViewModel.OpenMusicGalleryWindowCommand}">
|
||||
<TextBlock x:Uid="SystemTrayMusicGallery" />
|
||||
</Button>
|
||||
<DropDownButton x:Uid="SettingsPageAddFolderButton">
|
||||
<DropDownButton.Flyout>
|
||||
<MenuFlyout>
|
||||
<MenuFlyoutItem
|
||||
x:Uid="SettingsPageLocalFolder"
|
||||
Command="{x:Bind ViewModel.SelectAndAddFolderCommand}"
|
||||
CommandParameter="{Binding ElementName=RootGrid}"
|
||||
Icon="Folder" />
|
||||
Command="{x:Bind ViewModel.AddMediaSourceCommand}"
|
||||
CommandParameter="Local">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon FontFamily="{StaticResource IconFontFamily}" Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
|
||||
<MenuFlyoutSeparator Visibility="Collapsed" />
|
||||
<MenuFlyoutSeparator />
|
||||
|
||||
<MenuFlyoutItem
|
||||
Command="{x:Bind ViewModel.AddRemoteSourceCommand}"
|
||||
Command="{x:Bind ViewModel.AddMediaSourceCommand}"
|
||||
CommandParameter="SMB"
|
||||
Text="SMB"
|
||||
Visibility="Collapsed">
|
||||
Text="SMB">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon FontFamily="{StaticResource IconFontFamily}" Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
|
||||
<MenuFlyoutItem
|
||||
Command="{x:Bind ViewModel.AddRemoteSourceCommand}"
|
||||
Command="{x:Bind ViewModel.AddMediaSourceCommand}"
|
||||
CommandParameter="FTP"
|
||||
Text="FTP"
|
||||
Visibility="Collapsed">
|
||||
Text="FTP">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon FontFamily="{StaticResource IconFontFamily}" Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
|
||||
<MenuFlyoutItem
|
||||
Command="{x:Bind ViewModel.AddRemoteSourceCommand}"
|
||||
Command="{x:Bind ViewModel.AddMediaSourceCommand}"
|
||||
CommandParameter="WebDAV"
|
||||
Text="WebDAV"
|
||||
Visibility="Collapsed">
|
||||
Text="WebDAV">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon FontFamily="{StaticResource IconFontFamily}" Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
@@ -131,7 +176,7 @@
|
||||
</MenuFlyout>
|
||||
</DropDownButton.Flyout>
|
||||
</DropDownButton>
|
||||
</dev:SettingsCard>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.DependencyInjection;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.System;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
@@ -22,18 +23,14 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
|
||||
private void SettingsPageRemovePathButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
|
||||
{
|
||||
ViewModel.RemoveFolderAsync((MediaFolder)(sender as HyperlinkButton)!.Tag);
|
||||
var folder = (MediaFolder)((FrameworkElement)sender).DataContext;
|
||||
ViewModel.RemoveFolder(folder);
|
||||
}
|
||||
|
||||
private async void LocalFolderHyperlinkButton_Click(object sender, RoutedEventArgs e)
|
||||
private void SyncNowButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is HyperlinkButton button && button.Tag is string uriStr)
|
||||
{
|
||||
if (Uri.TryCreate(uriStr, UriKind.Absolute, out var uri))
|
||||
{
|
||||
await Launcher.LaunchUriAsync(uri);
|
||||
}
|
||||
}
|
||||
var folder = (MediaFolder)((FrameworkElement)sender).DataContext;
|
||||
ViewModel.SyncFolder(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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:dev="using:DevWinUI"
|
||||
xmlns:interactivity="using:Microsoft.Xaml.Interactivity"
|
||||
xmlns:local="using:BetterLyrics.WinUI3.Controls"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
@@ -11,7 +12,6 @@
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid x:Name="RootGrid">
|
||||
|
||||
<Grid
|
||||
x:Name="BottomCommandGrid"
|
||||
Background="{ThemeResource LayerOnMicaBaseAltFillColorDefaultBrush}"
|
||||
@@ -60,16 +60,16 @@
|
||||
</interactivity:Interaction.Behaviors>
|
||||
<Grid VerticalAlignment="Center" CornerRadius="4">
|
||||
<local:ImageSwitcher
|
||||
x:Name="AlbumArtImageSwitcher"
|
||||
Width="36"
|
||||
Height="36" />
|
||||
Height="36"
|
||||
Source="{x:Bind ViewModel.GSMTCService.AlbumArtBitmapImage, Mode=OneWay}" />
|
||||
</Grid>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock x:Name="TitleTextBlock" />
|
||||
<TextBlock Text="{x:Bind ViewModel.GSMTCService.CurrentSongInfo.Title, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
x:Name="ArtistsTextBlock"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind ViewModel.GSMTCService.CurrentSongInfo.DisplayArtists, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -113,7 +113,58 @@
|
||||
x:Name="BottomCenterCommandStackPanel"
|
||||
Padding="16"
|
||||
Orientation="Horizontal"
|
||||
Spacing="3">
|
||||
Spacing="12">
|
||||
<!-- Playback order -->
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Click="PlaybackOrderButton_Click"
|
||||
Style="{StaticResource GhostButtonStyle}"
|
||||
Visibility="{x:Bind ShowPlaybackOrderButton, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<ToolTipService.ToolTip>
|
||||
<ToolTip>
|
||||
<Grid>
|
||||
<TextBlock x:Name="PlaybackRepeatAllHint" x:Uid="MusicGalleryPageQueueLoop" />
|
||||
<TextBlock x:Name="PlaybackRepeatOneHint" x:Uid="MusicGalleryPageSingleLoop" />
|
||||
<TextBlock x:Name="PlaybackShuffleHint" x:Uid="MusicGalleryPageQueueRandom" />
|
||||
</Grid>
|
||||
</ToolTip>
|
||||
</ToolTipService.ToolTip>
|
||||
<Button.Content>
|
||||
<Grid>
|
||||
<!-- Repeat all -->
|
||||
<FontIcon
|
||||
x:Name="PlaybackRepeatAll"
|
||||
FontFamily="{StaticResource IconFontFamily}"
|
||||
FontSize="16"
|
||||
Glyph="">
|
||||
<FontIcon.OpacityTransition>
|
||||
<ScalarTransition />
|
||||
</FontIcon.OpacityTransition>
|
||||
</FontIcon>
|
||||
<!-- Repeat one -->
|
||||
<FontIcon
|
||||
x:Name="PlaybackRepeatOne"
|
||||
FontFamily="{StaticResource IconFontFamily}"
|
||||
FontSize="16"
|
||||
Glyph="">
|
||||
<FontIcon.OpacityTransition>
|
||||
<ScalarTransition />
|
||||
</FontIcon.OpacityTransition>
|
||||
</FontIcon>
|
||||
<!-- Shuffle -->
|
||||
<FontIcon
|
||||
x:Name="PlaybackShuffle"
|
||||
FontFamily="{StaticResource IconFontFamily}"
|
||||
FontSize="16"
|
||||
Glyph="">
|
||||
<FontIcon.OpacityTransition>
|
||||
<ScalarTransition />
|
||||
</FontIcon.OpacityTransition>
|
||||
</FontIcon>
|
||||
</Grid>
|
||||
</Button.Content>
|
||||
</Button>
|
||||
<!-- 上一曲目 -->
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.PreviousSongCommand}"
|
||||
Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily},
|
||||
@@ -126,13 +177,13 @@
|
||||
Style="{StaticResource GhostButtonStyle}">
|
||||
<interactivity:Interaction.Behaviors>
|
||||
<interactivity:DataTriggerBehavior
|
||||
Binding="{x:Bind ViewModel.MediaSessionsService.CurrentIsPlaying, Mode=OneWay}"
|
||||
Binding="{x:Bind ViewModel.GSMTCService.CurrentIsPlaying, Mode=OneWay}"
|
||||
ComparisonCondition="Equal"
|
||||
Value="True">
|
||||
<interactivity:ChangePropertyAction PropertyName="Visibility" Value="Visible" />
|
||||
</interactivity:DataTriggerBehavior>
|
||||
<interactivity:DataTriggerBehavior
|
||||
Binding="{x:Bind ViewModel.MediaSessionsService.CurrentIsPlaying, Mode=OneWay}"
|
||||
Binding="{x:Bind ViewModel.GSMTCService.CurrentIsPlaying, Mode=OneWay}"
|
||||
ComparisonCondition="Equal"
|
||||
Value="False">
|
||||
<interactivity:ChangePropertyAction PropertyName="Visibility" Value="Collapsed" />
|
||||
@@ -146,13 +197,13 @@
|
||||
Style="{StaticResource GhostButtonStyle}">
|
||||
<interactivity:Interaction.Behaviors>
|
||||
<interactivity:DataTriggerBehavior
|
||||
Binding="{x:Bind ViewModel.MediaSessionsService.CurrentIsPlaying, Mode=OneWay}"
|
||||
Binding="{x:Bind ViewModel.GSMTCService.CurrentIsPlaying, Mode=OneWay}"
|
||||
ComparisonCondition="Equal"
|
||||
Value="True">
|
||||
<interactivity:ChangePropertyAction PropertyName="Visibility" Value="Collapsed" />
|
||||
</interactivity:DataTriggerBehavior>
|
||||
<interactivity:DataTriggerBehavior
|
||||
Binding="{x:Bind ViewModel.MediaSessionsService.CurrentIsPlaying, Mode=OneWay}"
|
||||
Binding="{x:Bind ViewModel.GSMTCService.CurrentIsPlaying, Mode=OneWay}"
|
||||
ComparisonCondition="Equal"
|
||||
Value="False">
|
||||
<interactivity:ChangePropertyAction PropertyName="Visibility" Value="Visible" />
|
||||
@@ -164,6 +215,17 @@
|
||||
Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily},
|
||||
Glyph=}"
|
||||
Style="{StaticResource GhostButtonStyle}" />
|
||||
<!-- 播放队列按钮 -->
|
||||
<Button
|
||||
Click="PlayingQueueButton_Click"
|
||||
Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily},
|
||||
Glyph=}"
|
||||
Style="{StaticResource GhostButtonStyle}"
|
||||
Visibility="{x:Bind ShowPlayingQueueButton, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="MusicGalleryPagePlayingQueue" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -174,6 +236,19 @@
|
||||
Orientation="Horizontal"
|
||||
Spacing="3">
|
||||
|
||||
<!-- Stop media session -->
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.StopTrackCommand}"
|
||||
Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily},
|
||||
FontSize=16,
|
||||
Glyph=}"
|
||||
Style="{StaticResource GhostButtonStyle}"
|
||||
Visibility="{x:Bind ShowStopButton, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="MusicGalleryPageStopTrack" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
|
||||
<!-- Volume -->
|
||||
<Button Click="VolumeButton_Click" Style="{StaticResource GhostButtonStyle}">
|
||||
<Grid>
|
||||
@@ -345,10 +420,11 @@
|
||||
Margin="0,-14,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Top"
|
||||
Maximum="{x:Bind ViewModel.MediaSessionsService.CurrentSongInfo.DurationMs, Mode=OneWay, Converter={StaticResource MillisecondsToSecondsConverter}}"
|
||||
Maximum="{x:Bind ViewModel.GSMTCService.CurrentSongInfo.DurationMs, Mode=OneWay, Converter={StaticResource MillisecondsToSecondsConverter}}"
|
||||
Minimum="0"
|
||||
Style="{StaticResource GhostSliderStyle}"
|
||||
ThumbToolTipValueConverter="{StaticResource SecondsToFormattedTimeConverter}" />
|
||||
ThumbToolTipValueConverter="{StaticResource SecondsToFormattedTimeConverter}"
|
||||
Value="{x:Bind ViewModel.GSMTCService.CurrentPosition.TotalSeconds, Mode=OneWay}" />
|
||||
|
||||
<Grid
|
||||
x:Name="TimelineSliderLyricsLineInfo"
|
||||
@@ -427,7 +503,58 @@
|
||||
</Grid.ContextFlyout>
|
||||
</Grid>
|
||||
|
||||
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="PlaybackOrderState">
|
||||
<VisualState x:Name="RepeatAll">
|
||||
<VisualState.StateTriggers>
|
||||
<ui:CompareStateTrigger
|
||||
Comparison="Equal"
|
||||
Value="{x:Bind PlaybackOrder, Mode=OneWay, Converter={StaticResource EnumToIntConverter}}"
|
||||
To="0" />
|
||||
</VisualState.StateTriggers>
|
||||
<VisualState.Setters>
|
||||
<Setter Target="PlaybackRepeatAll.Opacity" Value="1" />
|
||||
<Setter Target="PlaybackRepeatOne.Opacity" Value="0" />
|
||||
<Setter Target="PlaybackShuffle.Opacity" Value="0" />
|
||||
<Setter Target="PlaybackRepeatAllHint.Visibility" Value="Visible" />
|
||||
<Setter Target="PlaybackRepeatOneHint.Visibility" Value="Collapsed" />
|
||||
<Setter Target="PlaybackShuffleHint.Visibility" Value="Collapsed" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="RepeatOne">
|
||||
<VisualState.StateTriggers>
|
||||
<ui:CompareStateTrigger
|
||||
Comparison="Equal"
|
||||
Value="{x:Bind PlaybackOrder, Mode=OneWay, Converter={StaticResource EnumToIntConverter}}"
|
||||
To="1" />
|
||||
</VisualState.StateTriggers>
|
||||
<VisualState.Setters>
|
||||
<Setter Target="PlaybackRepeatAll.Opacity" Value="0" />
|
||||
<Setter Target="PlaybackRepeatOne.Opacity" Value="1" />
|
||||
<Setter Target="PlaybackShuffle.Opacity" Value="0" />
|
||||
<Setter Target="PlaybackRepeatAllHint.Visibility" Value="Collapsed" />
|
||||
<Setter Target="PlaybackRepeatOneHint.Visibility" Value="Visible" />
|
||||
<Setter Target="PlaybackShuffleHint.Visibility" Value="Collapsed" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Shuffle">
|
||||
<VisualState.StateTriggers>
|
||||
<ui:CompareStateTrigger
|
||||
Comparison="Equal"
|
||||
Value="{x:Bind PlaybackOrder, Mode=OneWay, Converter={StaticResource EnumToIntConverter}}"
|
||||
To="2" />
|
||||
</VisualState.StateTriggers>
|
||||
<VisualState.Setters>
|
||||
<Setter Target="PlaybackRepeatAll.Opacity" Value="0" />
|
||||
<Setter Target="PlaybackRepeatOne.Opacity" Value="0" />
|
||||
<Setter Target="PlaybackShuffle.Opacity" Value="1" />
|
||||
<Setter Target="PlaybackRepeatAllHint.Visibility" Value="Collapsed" />
|
||||
<Setter Target="PlaybackRepeatOneHint.Visibility" Value="Collapsed" />
|
||||
<Setter Target="PlaybackShuffleHint.Visibility" Value="Visible" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
using BetterLyrics.WinUI3.Hooks;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Services.MediaSessionsService;
|
||||
using BetterLyrics.WinUI3.Models.Settings;
|
||||
using BetterLyrics.WinUI3.Services.GSMTCService;
|
||||
using BetterLyrics.WinUI3.ViewModels;
|
||||
using BetterLyrics.WinUI3.Views;
|
||||
using CommunityToolkit.Mvvm.DependencyInjection;
|
||||
@@ -12,21 +14,20 @@ using Microsoft.UI.Xaml.Input;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using BetterLyrics.WinUI3.Extensions;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace BetterLyrics.WinUI3.Controls;
|
||||
|
||||
public sealed partial class NowPlayingBar : UserControl,
|
||||
IRecipient<PropertyChangedMessage<SongInfo?>>,
|
||||
IRecipient<PropertyChangedMessage<BitmapImage?>>,
|
||||
IRecipient<PropertyChangedMessage<TimeSpan>>
|
||||
public sealed partial class NowPlayingBar : UserControl
|
||||
{
|
||||
public NowPlayingBarViewModel ViewModel => (NowPlayingBarViewModel)DataContext;
|
||||
|
||||
public event EventHandler? SongInfoTapped;
|
||||
public event EventHandler? TimeTapped;
|
||||
public event EventHandler? PlayQueueButtonClick;
|
||||
|
||||
public bool ShowTime
|
||||
{
|
||||
@@ -46,6 +47,42 @@ public sealed partial class NowPlayingBar : UserControl,
|
||||
public static readonly DependencyProperty ShowSongInfoProperty =
|
||||
DependencyProperty.Register(nameof(ShowSongInfo), typeof(bool), typeof(NowPlayingBar), new PropertyMetadata(false));
|
||||
|
||||
public bool ShowPlayingQueueButton
|
||||
{
|
||||
get { return (bool)GetValue(ShowPlayingQueueButtonProperty); }
|
||||
set { SetValue(ShowPlayingQueueButtonProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty ShowPlayingQueueButtonProperty =
|
||||
DependencyProperty.Register(nameof(ShowPlayingQueueButton), typeof(bool), typeof(NowPlayingBar), new PropertyMetadata(false));
|
||||
|
||||
public bool ShowPlaybackOrderButton
|
||||
{
|
||||
get { return (bool)GetValue(ShowPlaybackOrderButtonProperty); }
|
||||
set { SetValue(ShowPlaybackOrderButtonProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty ShowStopButtonProperty =
|
||||
DependencyProperty.Register(nameof(ShowStopButton), typeof(bool), typeof(NowPlayingBar), new PropertyMetadata(false));
|
||||
|
||||
public bool ShowStopButton
|
||||
{
|
||||
get { return (bool)GetValue(ShowStopButtonProperty); }
|
||||
set { SetValue(ShowStopButtonProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty ShowPlaybackOrderButtonProperty =
|
||||
DependencyProperty.Register(nameof(ShowPlaybackOrderButton), typeof(bool), typeof(NowPlayingBar), new PropertyMetadata(false));
|
||||
|
||||
public PlaybackOrder PlaybackOrder
|
||||
{
|
||||
get { return (PlaybackOrder)GetValue(PlaybackOrderProperty); }
|
||||
set { SetValue(PlaybackOrderProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty PlaybackOrderProperty =
|
||||
DependencyProperty.Register(nameof(PlaybackOrder), typeof(PlaybackOrder), typeof(NowPlayingBar), new PropertyMetadata(PlaybackOrder.RepeatAll));
|
||||
|
||||
public bool IsCompactMode
|
||||
{
|
||||
get { return (bool)GetValue(IsCompactModeProperty); }
|
||||
@@ -70,8 +107,6 @@ public sealed partial class NowPlayingBar : UserControl,
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = Ioc.Default.GetRequiredService<NowPlayingBarViewModel>();
|
||||
|
||||
WeakReferenceMessenger.Default.RegisterAll(this);
|
||||
}
|
||||
|
||||
private static void OnDependencyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
@@ -167,7 +202,7 @@ public sealed partial class NowPlayingBar : UserControl,
|
||||
var grid = (Grid)sender;
|
||||
var pos = e.GetCurrentPoint(grid).Position;
|
||||
var ratio = pos.X / grid.ActualWidth;
|
||||
ViewModel.MediaSessionsService.ChangePosition(TimelineSlider.Maximum * ratio);
|
||||
ViewModel.GSMTCService.ChangePosition(TimelineSlider.Maximum * ratio);
|
||||
}
|
||||
|
||||
private void TimelineSliderOverlay_PointerMoved(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
|
||||
@@ -210,12 +245,12 @@ public sealed partial class NowPlayingBar : UserControl,
|
||||
|
||||
private void SongInfoStackPanel_Tapped(object sender, TappedRoutedEventArgs e)
|
||||
{
|
||||
SongInfoTapped?.Invoke(this, EventArgs.Empty);
|
||||
SongInfoTapped?.Invoke(sender, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void TimeStackPanel_Tapped(object sender, TappedRoutedEventArgs e)
|
||||
{
|
||||
TimeTapped?.Invoke(this, EventArgs.Empty);
|
||||
TimeTapped?.Invoke(sender, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void BottomCommandGrid_PointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
|
||||
@@ -262,37 +297,13 @@ public sealed partial class NowPlayingBar : UserControl,
|
||||
}
|
||||
}
|
||||
|
||||
public void Receive(PropertyChangedMessage<SongInfo?> message)
|
||||
private void PlayingQueueButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (message.Sender is IMediaSessionsService)
|
||||
{
|
||||
if (message.PropertyName == nameof(IMediaSessionsService.CurrentSongInfo))
|
||||
{
|
||||
TitleTextBlock.Text = message.NewValue?.Title;
|
||||
ArtistsTextBlock.Text = message.NewValue?.DisplayArtists;
|
||||
}
|
||||
}
|
||||
}
|
||||
public void Receive(PropertyChangedMessage<BitmapImage?> message)
|
||||
{
|
||||
if (message.Sender is IMediaSessionsService)
|
||||
{
|
||||
if (message.PropertyName == nameof(IMediaSessionsService.AlbumArtBitmapImage))
|
||||
{
|
||||
AlbumArtImageSwitcher.Source = message.NewValue;
|
||||
}
|
||||
}
|
||||
PlayQueueButtonClick?.Invoke(sender, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Receive(PropertyChangedMessage<TimeSpan> message)
|
||||
private void PlaybackOrderButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (message.Sender is IMediaSessionsService)
|
||||
{
|
||||
if (message.PropertyName == nameof(IMediaSessionsService.CurrentPosition))
|
||||
{
|
||||
TimelineSlider.Value = message.NewValue.TotalSeconds;
|
||||
}
|
||||
}
|
||||
PlaybackOrder = PlaybackOrder.GetNext();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="BetterLyrics.WinUI3.Controls.PatronControl"
|
||||
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 Margin="12,8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{x:Bind PatronName, Mode=OneWay}" />
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind Date, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using Windows.Foundation;
|
||||
using Windows.Foundation.Collections;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace BetterLyrics.WinUI3.Controls
|
||||
{
|
||||
public sealed partial class PatronControl : UserControl
|
||||
{
|
||||
public string PatronName
|
||||
{
|
||||
get { return (string)GetValue(PatronNameProperty); }
|
||||
set { SetValue(PatronNameProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty PatronNameProperty =
|
||||
DependencyProperty.Register(nameof(PatronName), typeof(string), typeof(PatronControl), new PropertyMetadata(""));
|
||||
|
||||
public string Date
|
||||
{
|
||||
get { return (string)GetValue(DateProperty); }
|
||||
set { SetValue(DateProperty, value); }
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty DateProperty =
|
||||
DependencyProperty.Register(nameof(Date), typeof(string), typeof(PatronControl), new PropertyMetadata(""));
|
||||
|
||||
public PatronControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
142
BetterLyrics.WinUI3/BetterLyrics.WinUI3/Controls/PlayQueue.xaml
Normal file
142
BetterLyrics.WinUI3/BetterLyrics.WinUI3/Controls/PlayQueue.xaml
Normal file
@@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="BetterLyrics.WinUI3.Controls.PlayQueue"
|
||||
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:interactivity="using:Microsoft.Xaml.Interactivity"
|
||||
xmlns:local="using:BetterLyrics.WinUI3.Controls"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Grid.TranslationTransition>
|
||||
<Vector3Transition />
|
||||
</Grid.TranslationTransition>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Grid.Row="0" Margin="12,12,12,0">
|
||||
<TextBlock
|
||||
x:Uid="MusicGalleryPagePlayingQueue"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource BodyStrongTextBlockStyle}" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Margin="12,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel
|
||||
Grid.Column="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal">
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex, Mode=OneWay, Converter={StaticResource IndexToDisplayConverter}}" />
|
||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="/" />
|
||||
<TextBlock Text="{x:Bind ViewModel.SMTCService.TrackPlayingQueue.Count, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Scroll to playing item -->
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
Click="ScrollToPlayingItemButton_Click"
|
||||
Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily},
|
||||
FontSize=16,
|
||||
Glyph=}"
|
||||
Style="{StaticResource GhostButtonStyle}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="MusicGalleryPageScrollToPlayingItem" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
<!-- Empty play queue -->
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Right"
|
||||
Click="EmptyPlayingQueueButton_Click"
|
||||
Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily},
|
||||
FontSize=16,
|
||||
Glyph=}"
|
||||
Style="{StaticResource GhostButtonStyle}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="MusicGalleryPageEmptyPlayingQueue" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
|
||||
<NavigationViewItemSeparator Grid.Row="2" />
|
||||
|
||||
<ListView
|
||||
x:Name="PlayingQueueListView"
|
||||
Grid.Row="3"
|
||||
ItemsSource="{x:Bind ViewModel.SMTCService.TrackPlayingQueue, Mode=OneWay}"
|
||||
SelectedIndex="{x:Bind ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex, Mode=TwoWay}">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Padding="0,6">
|
||||
<Grid Tapped="PlayingQueueListVireItemGrid_Tapped">
|
||||
<StackPanel Margin="0,0,36,0">
|
||||
<TextBlock Text="{Binding Track.Title}" TextWrapping="Wrap" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{Binding Track.Artist}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Grid HorizontalAlignment="Right">
|
||||
<Button
|
||||
VerticalAlignment="Center"
|
||||
Click="RemoveFromPlayingQueueButton_Click"
|
||||
Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily},
|
||||
FontSize=16,
|
||||
Glyph=}"
|
||||
Style="{StaticResource GhostButtonStyle}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="MusicGalleryPageRemoveFromPlayingQueue" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
|
||||
<Grid Grid.Row="3">
|
||||
<interactivity:Interaction.Behaviors>
|
||||
<interactivity:DataTriggerBehavior
|
||||
Binding="{x:Bind ViewModel.SMTCService.TrackPlayingQueue.Count, Mode=OneWay}"
|
||||
ComparisonCondition="Equal"
|
||||
Value="0">
|
||||
<interactivity:ChangePropertyAction PropertyName="Visibility" Value="Visible" />
|
||||
</interactivity:DataTriggerBehavior>
|
||||
<interactivity:DataTriggerBehavior
|
||||
Binding="{x:Bind ViewModel.SMTCService.TrackPlayingQueue.Count, Mode=OneWay}"
|
||||
ComparisonCondition="NotEqual"
|
||||
Value="0">
|
||||
<interactivity:ChangePropertyAction PropertyName="Visibility" Value="Collapsed" />
|
||||
</interactivity:DataTriggerBehavior>
|
||||
</interactivity:Interaction.Behaviors>
|
||||
<StackPanel
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12">
|
||||
<Image MaxWidth="100" Source="/Assets/EmptyBox.png" />
|
||||
<TextBlock
|
||||
x:Uid="MusicGalleryPagePlayingQueueEmpty"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,103 @@
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Models.Settings;
|
||||
using BetterLyrics.WinUI3.ViewModels;
|
||||
using CommunityToolkit.Mvvm.DependencyInjection;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using Windows.Foundation;
|
||||
using Windows.Foundation.Collections;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace BetterLyrics.WinUI3.Controls
|
||||
{
|
||||
public sealed partial class PlayQueue : UserControl, IRecipient<PropertyChangedMessage<int>>
|
||||
{
|
||||
public PlayQueueViewModel ViewModel => (PlayQueueViewModel)DataContext;
|
||||
public PlayQueue()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = Ioc.Default.GetRequiredService<PlayQueueViewModel>();
|
||||
WeakReferenceMessenger.Default.RegisterAll(this);
|
||||
}
|
||||
|
||||
private void ScrollToPlayingItem()
|
||||
{
|
||||
if (PlayingQueueListView == null) return;
|
||||
|
||||
var targetItem = ViewModel.SMTCService.TrackPlayingQueue
|
||||
.ElementAtOrDefault(ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex);
|
||||
if (targetItem == null) return;
|
||||
|
||||
PlayingQueueListView.ScrollIntoView(targetItem);
|
||||
}
|
||||
|
||||
private void ScrollToPlayingItemButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ScrollToPlayingItem();
|
||||
}
|
||||
|
||||
private async void PlayingQueueListVireItemGrid_Tapped(object sender, TappedRoutedEventArgs e)
|
||||
{
|
||||
var item = (PlayQueueItem)((FrameworkElement)sender).DataContext;
|
||||
await ViewModel.SMTCService.PlayTrackAsync(item);
|
||||
}
|
||||
|
||||
private async void RemoveFromPlayingQueueButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
bool playNext = false;
|
||||
var item = (PlayQueueItem)((FrameworkElement)sender).DataContext;
|
||||
int index = ViewModel.SMTCService.TrackPlayingQueue.IndexOf(item);
|
||||
if (item == PlayingQueueListView.SelectedItem)
|
||||
{
|
||||
playNext = true;
|
||||
}
|
||||
ViewModel.SMTCService.TrackPlayingQueue.Remove(item);
|
||||
if (playNext)
|
||||
{
|
||||
if (ViewModel.SMTCService.TrackPlayingQueue.Count == 0)
|
||||
{
|
||||
index = -1;
|
||||
}
|
||||
else if (index >= ViewModel.SMTCService.TrackPlayingQueue.Count)
|
||||
{
|
||||
index = ViewModel.SMTCService.TrackPlayingQueue.Count - 1;
|
||||
}
|
||||
ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex = index;
|
||||
await ViewModel.SMTCService.PlayTrackAtAsync(ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private async void EmptyPlayingQueueButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel.SMTCService.TrackPlayingQueue.Clear();
|
||||
ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex = -1;
|
||||
await ViewModel.SMTCService.PlayTrackAtAsync(ViewModel.AppSettings.MusicGallerySettings.PlayQueueIndex);
|
||||
}
|
||||
|
||||
public void Receive(PropertyChangedMessage<int> message)
|
||||
{
|
||||
if (message.Sender is MusicGallerySettings)
|
||||
{
|
||||
if (message.PropertyName == nameof(MusicGallerySettings.PlayQueueIndex))
|
||||
{
|
||||
ScrollToPlayingItem();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -314,8 +314,8 @@
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<local:PropertyRow x:Uid="SettingsPagePlaybackSource" Value="{x:Bind ViewModel.MediaSessionsService.CurrentMediaSourceProviderInfo.DisplayName, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPagePlaybackSourceID" Value="{x:Bind ViewModel.MediaSessionsService.CurrentSongInfo.PlayerId, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPagePlaybackSource" Value="{x:Bind ViewModel.GSMTCService.CurrentMediaSourceProviderInfo.DisplayName, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPagePlaybackSourceID" Value="{x:Bind ViewModel.GSMTCService.CurrentSongInfo.PlayerId, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
|
||||
@@ -325,13 +325,10 @@
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<local:PropertyRow x:Uid="SettingsPageSongTitle" Value="{x:Bind ViewModel.MediaSessionsService.CurrentSongInfo.Title, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageArtist" Value="{x:Bind ViewModel.MediaSessionsService.CurrentSongInfo.DisplayArtists, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageAlbum" Value="{x:Bind ViewModel.MediaSessionsService.CurrentSongInfo.Album, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow
|
||||
x:Uid="LyricsSearchControlDurauion"
|
||||
Unit="s"
|
||||
Value="{x:Bind ViewModel.MediaSessionsService.CurrentSongInfo.Duration, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageSongTitle" Value="{x:Bind ViewModel.GSMTCService.CurrentSongInfo.Title, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageArtist" Value="{x:Bind ViewModel.GSMTCService.CurrentSongInfo.DisplayArtists, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageAlbum" Value="{x:Bind ViewModel.GSMTCService.CurrentSongInfo.Album, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="LyricsSearchControlDurauion" Value="{x:Bind ViewModel.GSMTCService.CurrentSongInfo.DurationMs, TargetNullValue=N/A, Converter={StaticResource MillisecondsToFormattedTimeConverter}, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
|
||||
@@ -341,29 +338,26 @@
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<local:PropertyRow x:Uid="SettingsPageSongTitle" Value="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.Title, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageArtist" Value="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.DisplayArtists, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageAlbum" Value="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.Album, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow
|
||||
x:Uid="LyricsSearchControlDurauion"
|
||||
Unit="s"
|
||||
Value="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.Duration, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="LyricsPageLanguageCode" Value="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsData.LanguageCode, TargetNullValue=N/A, Mode=OneWay, Converter={StaticResource LanguageCodeToDisplayedNameConverter}}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageSongTitle" Value="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.Title, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageArtist" Value="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.DisplayArtists, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="SettingsPageAlbum" Value="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.Album, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="LyricsSearchControlDurauion" Value="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.Duration, Converter={StaticResource SecondsToFormattedTimeConverter}, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
<local:PropertyRow x:Uid="LyricsPageLanguageCode" Value="{x:Bind ViewModel.GSMTCService.CurrentLyricsData.LanguageCode, TargetNullValue=N/A, Mode=OneWay, Converter={StaticResource LanguageCodeToDisplayedNameConverter}}" />
|
||||
<local:PropertyRow
|
||||
x:Uid="LyricsPageLyricsProviderPrefix"
|
||||
Link="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.Reference, Mode=OneWay}"
|
||||
ToolTipService.ToolTip="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.Reference, TargetNullValue=N/A, Mode=OneWay}"
|
||||
Value="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.ProviderIfFound, Mode=OneWay, Converter={StaticResource LyricsSearchProviderToDisplayNameConverter}}" />
|
||||
<local:PropertyRow x:Uid="LyricsPageTransliterationProviderPrefix" Value="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.TransliterationProvider, Mode=OneWay, Converter={StaticResource TransliterationSearchProviderToDisplayNameConverter}}" />
|
||||
<local:PropertyRow x:Uid="LyricsPageTranslationProviderPrefix" Value="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.TranslationProvider, Mode=OneWay, Converter={StaticResource TranslationSearchProviderToDisplayNameConverter}}" />
|
||||
Link="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.Reference, Mode=OneWay, Converter={StaticResource UriStringToDecodedAbsoluteUri}}"
|
||||
ToolTipService.ToolTip="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.Reference, TargetNullValue=N/A, Mode=OneWay, Converter={StaticResource UriStringToDecodedAbsoluteUri}}"
|
||||
Value="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.ProviderIfFound, Mode=OneWay, Converter={StaticResource LyricsSearchProviderToDisplayNameConverter}}" />
|
||||
<local:PropertyRow x:Uid="LyricsPageTransliterationProviderPrefix" Value="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.TransliterationProvider, Mode=OneWay, Converter={StaticResource TransliterationSearchProviderToDisplayNameConverter}}" />
|
||||
<local:PropertyRow x:Uid="LyricsPageTranslationProviderPrefix" Value="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.TranslationProvider, Mode=OneWay, Converter={StaticResource TranslationSearchProviderToDisplayNameConverter}}" />
|
||||
<local:PropertyRow
|
||||
x:Uid="LyricsPageMatchPercentage"
|
||||
Unit="%"
|
||||
Value="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.MatchPercentage, Mode=OneWay}" />
|
||||
Value="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.MatchPercentage, Mode=OneWay}" />
|
||||
<local:PropertyRow
|
||||
x:Uid="LyricsPageCachePath"
|
||||
Link="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.SelfPath, TargetNullValue=N/A, Mode=OneWay}"
|
||||
ToolTipService.ToolTip="{x:Bind ViewModel.MediaSessionsService.CurrentLyricsSearchResult.SelfPath, TargetNullValue=N/A, Mode=OneWay}" />
|
||||
Link="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.SelfPath, TargetNullValue=N/A, Mode=OneWay, Converter={StaticResource UriStringToDecodedAbsoluteUri}}"
|
||||
ToolTipService.ToolTip="{x:Bind ViewModel.GSMTCService.CurrentLyricsSearchResult.SelfPath, TargetNullValue=N/A, Mode=OneWay, Converter={StaticResource UriStringToDecodedAbsoluteUri}}" />
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
|
||||
|
||||
@@ -9,56 +9,85 @@
|
||||
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" />
|
||||
<ScrollViewer>
|
||||
<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"
|
||||
InputScope="Url"
|
||||
PlaceholderText="192.168.1.x"
|
||||
x:Name="NameBox"
|
||||
x:Uid="RemoteServerConfigControlName"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<NumberBox
|
||||
x:Name="PortBox"
|
||||
x:Uid="RemoteServerConfigControlPort"
|
||||
Grid.Column="1"
|
||||
MinWidth="100"
|
||||
LargeChange="10"
|
||||
SmallChange="1"
|
||||
SpinButtonPlacementMode="Inline"
|
||||
ToolTipService.ToolTip="80"
|
||||
Value="80" />
|
||||
</Grid>
|
||||
<StackPanel x:Name="RemoteFieldsPanel" Spacing="16">
|
||||
<Grid ColumnDefinitions="*, Auto" ColumnSpacing="12">
|
||||
<TextBox
|
||||
x:Name="HostBox"
|
||||
x:Uid="RemoteServerConfigControlServerAddress"
|
||||
Grid.Column="0"
|
||||
InputScope="Url"
|
||||
PlaceholderText="192.168.1.x"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBox
|
||||
x:Name="PathBox"
|
||||
x:Uid="RemoteServerConfigControlPath"
|
||||
TextWrapping="Wrap" />
|
||||
<NumberBox
|
||||
x:Name="PortBox"
|
||||
x:Uid="RemoteServerConfigControlPort"
|
||||
Grid.Column="1"
|
||||
MinWidth="100"
|
||||
LargeChange="10"
|
||||
SmallChange="1"
|
||||
SpinButtonPlacementMode="Inline"
|
||||
ToolTipService.ToolTip="80"
|
||||
Value="80" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="*, *" ColumnSpacing="12">
|
||||
<TextBox
|
||||
x:Name="UserBox"
|
||||
x:Uid="RemoteServerConfigControlUsername"
|
||||
Grid.Column="0"
|
||||
TextWrapping="Wrap" />
|
||||
<Grid ColumnDefinitions="*, Auto" ColumnSpacing="8">
|
||||
<TextBox
|
||||
x:Name="PathBox"
|
||||
x:Uid="RemoteServerConfigControlPath"
|
||||
Grid.Column="0"
|
||||
TextChanged="PathBox_TextChanged"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<PasswordBox
|
||||
x:Name="PwdBox"
|
||||
x:Uid="RemoteServerConfigControlPassword"
|
||||
Grid.Column="1"
|
||||
PasswordRevealMode="Peek" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<Button
|
||||
x:Name="BrowseButton"
|
||||
x:Uid="RemoteServerConfigControlBrowse"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Bottom"
|
||||
Click="BrowseButton_Click"
|
||||
Visibility="Collapsed" />
|
||||
</Grid>
|
||||
|
||||
<InfoBar
|
||||
x:Name="PathWarningBar"
|
||||
IsClosable="False"
|
||||
IsOpen="False"
|
||||
Severity="Warning" />
|
||||
|
||||
<StackPanel x:Name="AuthFieldsPanel" Spacing="16">
|
||||
<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>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Services.LocalizationService;
|
||||
using BetterLyrics.WinUI3.Views;
|
||||
using CommunityToolkit.Mvvm.DependencyInjection;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
@@ -10,96 +12,184 @@ namespace BetterLyrics.WinUI3.Controls
|
||||
{
|
||||
public sealed partial class RemoteServerConfigControl : UserControl
|
||||
{
|
||||
private readonly string _protocolType;
|
||||
private readonly FileSourceType _fileSourceType;
|
||||
private readonly ILocalizationService _localizationService = Ioc.Default.GetRequiredService<ILocalizationService>();
|
||||
|
||||
public RemoteServerConfigControl(string protocolType)
|
||||
public RemoteServerConfigControl(FileSourceType fileSourceType)
|
||||
{
|
||||
this.InitializeComponent();
|
||||
_protocolType = protocolType;
|
||||
_fileSourceType = fileSourceType;
|
||||
|
||||
SetupDefaults();
|
||||
CheckPathForWarning();
|
||||
}
|
||||
|
||||
private void SetupDefaults()
|
||||
{
|
||||
switch (_protocolType.ToUpper())
|
||||
if (_fileSourceType == FileSourceType.Local)
|
||||
{
|
||||
case "SMB":
|
||||
PortBox.Value = 445; // SMB Ĭ<>϶˿<CFB6>
|
||||
PathBox.PlaceholderText = "SharedMusic";
|
||||
RemoteFieldsPanel.Visibility = Visibility.Collapsed;
|
||||
AuthFieldsPanel.Visibility = Visibility.Collapsed;
|
||||
|
||||
BrowseButton.Visibility = Visibility.Visible;
|
||||
|
||||
PathBox.PlaceholderText = @"D:\Music";
|
||||
}
|
||||
else
|
||||
{
|
||||
BrowseButton.Visibility = Visibility.Collapsed;
|
||||
RemoteFieldsPanel.Visibility = Visibility.Visible;
|
||||
AuthFieldsPanel.Visibility = Visibility.Visible;
|
||||
|
||||
switch (_fileSourceType)
|
||||
{
|
||||
case FileSourceType.SMB:
|
||||
PortBox.Value = 445;
|
||||
PathBox.PlaceholderText = "SharedMusic";
|
||||
break;
|
||||
case FileSourceType.FTP:
|
||||
PortBox.Value = 21;
|
||||
PathBox.PlaceholderText = "/pub/music";
|
||||
break;
|
||||
case FileSourceType.WebDAV:
|
||||
PortBox.Value = 80;
|
||||
PathBox.PlaceholderText = "/dav/music";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetScheme()
|
||||
{
|
||||
string scheme = string.Empty;
|
||||
switch (_fileSourceType)
|
||||
{
|
||||
case FileSourceType.SMB:
|
||||
scheme = "smb";
|
||||
break;
|
||||
case "FTP":
|
||||
PortBox.Value = 21; // FTP Ĭ<>϶˿<CFB6>
|
||||
PathBox.PlaceholderText = "/pub/music";
|
||||
case FileSourceType.FTP:
|
||||
scheme = "ftp";
|
||||
break;
|
||||
case "WEBDAV":
|
||||
PortBox.Value = 80; // WebDAV Ĭ<>϶˿<CFB6>
|
||||
PathBox.PlaceholderText = "/dav/music";
|
||||
case FileSourceType.WebDAV:
|
||||
scheme = "https";
|
||||
break;
|
||||
}
|
||||
return scheme;
|
||||
}
|
||||
|
||||
public MediaFolder GetConfig()
|
||||
{
|
||||
string finalName = HostBox.Text.Trim();
|
||||
|
||||
if (_fileSourceType == FileSourceType.Local)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PathBox.Text))
|
||||
throw new ArgumentException(_localizationService.GetLocalizedString("RemoteServerConfigControlPathRequired"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(NameBox.Text))
|
||||
finalName = NameBox.Text.Trim();
|
||||
else
|
||||
finalName = PathBox.Text.TrimEnd(System.IO.Path.DirectorySeparatorChar);
|
||||
|
||||
return new MediaFolder
|
||||
{
|
||||
Name = finalName,
|
||||
SourceType = FileSourceType.Local,
|
||||
UriScheme = "file",
|
||||
UriPath = PathBox.Text.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(HostBox.Text))
|
||||
throw new ArgumentException(_localizationService.GetLocalizedString("RemoteServerConfigControlServerAddressRequired"));
|
||||
|
||||
string name = $"{_protocolType} - {HostBox.Text}";
|
||||
if (!string.IsNullOrWhiteSpace(NameBox.Text))
|
||||
{
|
||||
finalName = NameBox.Text.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
finalName = $"{_fileSourceType} - {HostBox.Text}";
|
||||
}
|
||||
|
||||
Enum.TryParse(_protocolType, true, out FileSourceType sourceType);
|
||||
string scheme = GetScheme();
|
||||
|
||||
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 <EFBFBD><EFBFBD>ȡ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
SourceType = sourceType,
|
||||
IsRealTimeWatchEnabled = false
|
||||
Name = finalName,
|
||||
SourceType = _fileSourceType,
|
||||
|
||||
UriScheme = scheme,
|
||||
UriHost = HostBox.Text.Trim(), // ȥ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>β<EFBFBD>ո<EFBFBD>
|
||||
UriPort = (int)PortBox.Value,
|
||||
|
||||
UriPath = PathBox.Text.Trim(),
|
||||
|
||||
UserName = UserBox.Text.Trim(),
|
||||
Password = PwdBox.Password,
|
||||
};
|
||||
|
||||
// <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)
|
||||
public void ShowError(string? message)
|
||||
{
|
||||
ErrorInfoBar.Message = message;
|
||||
ErrorInfoBar.IsOpen = true;
|
||||
ErrorInfoBar.IsOpen = !string.IsNullOrWhiteSpace(message);
|
||||
}
|
||||
|
||||
public void SetProgressBarVisibility(Visibility visibility)
|
||||
{
|
||||
ProgressBar.Visibility = visibility;
|
||||
}
|
||||
|
||||
private void CheckPathForWarning()
|
||||
{
|
||||
string? path = PathBox.Text?.Trim();
|
||||
|
||||
bool isSymbolRoot = string.IsNullOrEmpty(path) ||
|
||||
path == "/" ||
|
||||
path == "\\";
|
||||
|
||||
bool isDriveRoot = false;
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
var normalized = path.TrimEnd('\\', '/');
|
||||
isDriveRoot = normalized.EndsWith(":") && normalized.Length == 2;
|
||||
}
|
||||
|
||||
bool isRoot = isSymbolRoot || isDriveRoot;
|
||||
|
||||
if (isRoot)
|
||||
{
|
||||
PathWarningBar.Message = _localizationService.GetLocalizedString("FileSystemServiceRootDirectoryWarning");
|
||||
PathWarningBar.IsOpen = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
PathWarningBar.IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void PathBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
CheckPathForWarning();
|
||||
}
|
||||
|
||||
private async void BrowseButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var folder = await PickerHelper.PickSingleFolderAsync<SettingsWindow>();
|
||||
if (folder != null)
|
||||
{
|
||||
PathBox.Text = folder.Path;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="BetterLyrics.WinUI3.Controls.StatsDashboardControl"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:converters="using:BetterLyrics.WinUI3.Converter"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:dev="using:DevWinUI"
|
||||
xmlns:local="using:BetterLyrics.WinUI3.Controls"
|
||||
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.WinUI"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:models="using:BetterLyrics.WinUI3.Models"
|
||||
xmlns:statsmodels="using:BetterLyrics.WinUI3.Models.Stats"
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<UserControl.Resources>
|
||||
<Style x:Key="StatsCardStyle" TargetType="Border">
|
||||
<Setter Property="Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
<Setter Property="Margin" Value="0,0,12,12" />
|
||||
</Style>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<ProgressBar
|
||||
Grid.Row="0"
|
||||
Background="Transparent"
|
||||
IsIndeterminate="{x:Bind ViewModel.IsLoading, Mode=OneWay}"
|
||||
Visibility="{x:Bind ViewModel.IsLoading, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
|
||||
<Grid Grid.Row="1" Visibility="{x:Bind ViewModel.GSMTCService.IsScrobbled, Mode=OneWay, Converter={StaticResource BoolNegationToVisibilityConverter}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<InfoBar
|
||||
x:Uid="StatsDashboardControlRecording"
|
||||
Grid.Row="0"
|
||||
IsClosable="False"
|
||||
IsOpen="True"
|
||||
Message="{x:Bind ViewModel.GSMTCService.CurrentSongInfo.Title, Mode=OneWay}" />
|
||||
<ProgressBar
|
||||
Grid.Row="1"
|
||||
Background="Transparent"
|
||||
Maximum="{x:Bind ViewModel.GSMTCService.TargetScrobbledDuration.TotalSeconds, Mode=OneWay}"
|
||||
ShowPaused="{x:Bind ViewModel.GSMTCService.CurrentIsPlaying, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
|
||||
Value="{x:Bind ViewModel.GSMTCService.ScrobbledDuration.TotalSeconds, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<controls:WrapPanel
|
||||
Grid.Row="2"
|
||||
Margin="36,36,36,12"
|
||||
HorizontalSpacing="12"
|
||||
Orientation="Horizontal"
|
||||
VerticalSpacing="12">
|
||||
<ComboBox
|
||||
x:Uid="StatsDashboardControlTimeRange"
|
||||
Header="Time Range"
|
||||
SelectedIndex="{x:Bind ViewModel.SelectedTimeRange, Mode=TwoWay, Converter={StaticResource EnumToIntConverter}}">
|
||||
<ComboBoxItem x:Uid="StatsDashboardControlToday" />
|
||||
<ComboBoxItem x:Uid="StatsDashboardControlThisWeek" />
|
||||
<ComboBoxItem x:Uid="StatsDashboardControlThisMonth" />
|
||||
<ComboBoxItem x:Uid="StatsDashboardControlThisQuarter" />
|
||||
<ComboBoxItem x:Uid="StatsDashboardControlThisYear" />
|
||||
<ComboBoxItem x:Uid="StatsDashboardControlCustom" />
|
||||
</ComboBox>
|
||||
|
||||
<CalendarDatePicker
|
||||
x:Uid="StatsDashboardControlStart"
|
||||
Date="{x:Bind ViewModel.CustomStartDate, Mode=TwoWay}"
|
||||
IsEnabled="{x:Bind ViewModel.IsCustomRangeSelected, Mode=OneWay}" />
|
||||
<TimePicker
|
||||
VerticalAlignment="Bottom"
|
||||
IsEnabled="{x:Bind ViewModel.IsCustomRangeSelected, Mode=OneWay}"
|
||||
Time="{x:Bind ViewModel.CustomStartTime, Mode=TwoWay}" />
|
||||
<CalendarDatePicker
|
||||
x:Uid="StatsDashboardControlEnd"
|
||||
Date="{x:Bind ViewModel.CustomEndDate, Mode=TwoWay}"
|
||||
IsEnabled="{x:Bind ViewModel.IsCustomRangeSelected, Mode=OneWay}" />
|
||||
<TimePicker
|
||||
VerticalAlignment="Bottom"
|
||||
IsEnabled="{x:Bind ViewModel.IsCustomRangeSelected, Mode=OneWay}"
|
||||
Time="{x:Bind ViewModel.CustomEndTime, Mode=TwoWay}" />
|
||||
<Button
|
||||
VerticalAlignment="Bottom"
|
||||
Command="{x:Bind ViewModel.RefreshDataCommand}"
|
||||
Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily},
|
||||
FontSize=16,
|
||||
Glyph=}" />
|
||||
</controls:WrapPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="3" Padding="36,0">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition />
|
||||
<RowDefinition />
|
||||
<RowDefinition />
|
||||
<RowDefinition />
|
||||
<RowDefinition />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Style="{StaticResource StatsCardStyle}">
|
||||
<StackPanel>
|
||||
<StackPanel
|
||||
Opacity="0.8"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock x:Uid="StatsDashboardControlTotalDuration" Style="{ThemeResource CaptionTextBlockStyle}" />
|
||||
</StackPanel>
|
||||
<StackPanel
|
||||
Margin="0,8,0,0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="4">
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||
Style="{ThemeResource SubtitleTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.TotalDuration.TotalHours, Mode=OneWay, Converter={StaticResource DoubleToDecimalConverter}}" />
|
||||
<TextBlock
|
||||
Margin="0,0,0,2"
|
||||
VerticalAlignment="Bottom"
|
||||
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||
Opacity="0.8"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Text="Hrs" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1" Style="{StaticResource StatsCardStyle}">
|
||||
<StackPanel>
|
||||
<StackPanel
|
||||
Opacity="0.8"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock x:Uid="StatsDashboardControlTracksPlayed" Style="{ThemeResource CaptionTextBlockStyle}" />
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Margin="0,8,0,0"
|
||||
Style="{ThemeResource SubtitleTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.TotalTracksPlayed, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
Grid.Column="2"
|
||||
Margin="0,0,0,12"
|
||||
Style="{StaticResource StatsCardStyle}">
|
||||
<StackPanel>
|
||||
<StackPanel
|
||||
Opacity="0.8"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock x:Uid="StatsDashboardControlTopSource" Style="{ThemeResource CaptionTextBlockStyle}" />
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Margin="0,8,0,0"
|
||||
Style="{ThemeResource SubtitleTextBlockStyle}"
|
||||
Text="{x:Bind ViewModel.TopPlayerName, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Activity by hour -->
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Margin="0,0,0,12"
|
||||
Style="{StaticResource StatsCardStyle}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel
|
||||
Margin="0,0,0,12"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<TextBlock x:Uid="StatsDashboardControlActivityByHour" Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" Margin="0,0,0,16">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
x:Uid="StatsDashboardControlMostActive"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
|
||||
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.PeakHourText, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock
|
||||
x:Uid="StatsDashboardControlLeastActive"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
|
||||
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.QuietHourText, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<lvc:CartesianChart
|
||||
Grid.Row="2"
|
||||
Height="180"
|
||||
Margin="0,8,0,0"
|
||||
Background="Transparent"
|
||||
TooltipPosition="Top">
|
||||
|
||||
<lvc:CartesianChart.XAxes>
|
||||
<lvc:AxesCollection>
|
||||
<lvc:XamlAxis Labels="{x:Bind ViewModel.HourlyXAxisLabels, Mode=OneWay}" TextSize="{StaticResource BodyTextBlockFontSize}" />
|
||||
</lvc:AxesCollection>
|
||||
</lvc:CartesianChart.XAxes>
|
||||
|
||||
<lvc:CartesianChart.YAxes>
|
||||
<lvc:AxesCollection>
|
||||
<lvc:XamlAxis
|
||||
x:Uid="StatsDashboardControlTrackCountAxis"
|
||||
NameTextSize="{StaticResource BodyTextBlockFontSize}"
|
||||
ShowSeparatorLines="False"
|
||||
TextSize="{StaticResource BodyTextBlockFontSize}" />
|
||||
</lvc:AxesCollection>
|
||||
</lvc:CartesianChart.YAxes>
|
||||
|
||||
<lvc:CartesianChart.Series>
|
||||
<lvc:SeriesCollection>
|
||||
<lvc:XamlColumnSeries
|
||||
x:Name="HourlySeries"
|
||||
Rx="4"
|
||||
Ry="4"
|
||||
Values="{x:Bind ViewModel.HourlySeriesValues, Mode=OneWay}" />
|
||||
</lvc:SeriesCollection>
|
||||
</lvc:CartesianChart.Series>
|
||||
|
||||
</lvc:CartesianChart>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Top artists and sources -->
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Top artists -->
|
||||
<Border Grid.Column="0" Style="{StaticResource StatsCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
x:Uid="StatsDashboardControlTopArtists"
|
||||
Margin="0,0,0,12"
|
||||
Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
|
||||
<ItemsControl ItemsSource="{x:Bind ViewModel.TopArtists, Mode=OneWay}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="statsmodels:ArtistPlayCount">
|
||||
<Grid Margin="0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<PersonPicture
|
||||
Width="32"
|
||||
Height="32"
|
||||
DisplayName="{x:Bind Artist}" />
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Style="{ThemeResource BodyStrongTextBlockStyle}"
|
||||
Text="{x:Bind Artist}" />
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold">
|
||||
<Run Text="{x:Bind PlayCount}" />
|
||||
<Run
|
||||
x:Uid="StatsDashboardControlTrackCountText"
|
||||
FontSize="10"
|
||||
FontWeight="Normal"
|
||||
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Top tracks -->
|
||||
<Border
|
||||
Grid.Column="1"
|
||||
Margin="0,0,0,12"
|
||||
Style="{StaticResource StatsCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
x:Uid="StatsDashboardControlTopSongs"
|
||||
Margin="0,0,0,12"
|
||||
Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
<ItemsControl ItemsSource="{x:Bind ViewModel.TopSongs, Mode=OneWay}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="statsmodels:SongPlayCount">
|
||||
<Grid Margin="0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid
|
||||
Width="40"
|
||||
Height="40"
|
||||
Margin="0,0,12,0"
|
||||
Background="{ThemeResource LayerFillColorAltBrush}"
|
||||
CornerRadius="4">
|
||||
<FontIcon
|
||||
FontSize="16"
|
||||
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}"
|
||||
Glyph="" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Style="{ThemeResource BodyStrongTextBlockStyle}" Text="{x:Bind Title}" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Artist}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold">
|
||||
<Run Text="{x:Bind PlayCount}" />
|
||||
<Run
|
||||
x:Uid="StatsDashboardControlTrackCountText"
|
||||
FontSize="10"
|
||||
FontWeight="Normal"
|
||||
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- 播放源分布 -->
|
||||
<Border
|
||||
Grid.Row="3"
|
||||
Margin="0,0,0,20"
|
||||
Style="{StaticResource StatsCardStyle}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock
|
||||
x:Uid="StatsDashboardControlSources"
|
||||
Margin="0,0,0,12"
|
||||
Style="{ThemeResource SubtitleTextBlockStyle}" />
|
||||
|
||||
<lvc:PieChart
|
||||
Grid.Row="1"
|
||||
MinHeight="250"
|
||||
Background="Transparent"
|
||||
LegendPosition="Bottom"
|
||||
LegendTextSize="{StaticResource BodyTextBlockFontSize}"
|
||||
Series="{x:Bind ViewModel.SourceSeries, Mode=OneWay}"
|
||||
TooltipPosition="Center" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--<Button
|
||||
Grid.Row="2"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Command="{x:Bind ViewModel.GenerateTestDataCommand}"
|
||||
Content="Generate test data" />-->
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using Windows.Foundation;
|
||||
using Windows.Foundation.Collections;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Controls.Primitives;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
using BetterLyrics.WinUI3.ViewModels;
|
||||
using CommunityToolkit.Mvvm.DependencyInjection;
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
|
||||
// To learn more about WinUI, the WinUI project structure,
|
||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
||||
|
||||
namespace BetterLyrics.WinUI3.Controls;
|
||||
|
||||
public sealed partial class StatsDashboardControl : UserControl
|
||||
{
|
||||
public StatsDashboardControlViewModel ViewModel => (StatsDashboardControlViewModel)DataContext;
|
||||
|
||||
public StatsDashboardControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = Ioc.Default.GetRequiredService<StatsDashboardControlViewModel>();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,13 +18,7 @@
|
||||
<TextBlock x:Uid="AppSettingsControlGeneral" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||
|
||||
<dev:SettingsCard x:Uid="SettingsPageConfigName" HeaderIcon="{ui:FontIcon FontFamily={StaticResource IconFontFamily}, Glyph=}">
|
||||
<StackPanel
|
||||
Margin="0,6,0,0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<TextBox Text="{x:Bind LyricsWindowStatus.Name, Mode=TwoWay}" TextWrapping="Wrap" />
|
||||
<Button Content="{ui:FontIcon FontFamily={StaticResource IconFontFamily}, FontSize=12, Glyph=}" Style="{StaticResource GhostButtonStyle}" />
|
||||
</StackPanel>
|
||||
<TextBox Text="{x:Bind LyricsWindowStatus.Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" />
|
||||
</dev:SettingsCard>
|
||||
|
||||
<dev:SettingsExpander
|
||||
|
||||
@@ -17,20 +17,16 @@ namespace BetterLyrics.WinUI3.Converter
|
||||
using (var ms = new MemoryStream(byteArray))
|
||||
{
|
||||
var stream = ms.AsRandomAccessStream();
|
||||
|
||||
var bitmapImage = new BitmapImage();
|
||||
|
||||
bitmapImage.SetSource(stream);
|
||||
|
||||
return bitmapImage;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return PathHelper.AlbumArtPlaceholderPath;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return PathHelper.AlbumArtPlaceholderPath;
|
||||
return new BitmapImage(new Uri(PathHelper.AlbumArtPlaceholderPath));
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Converter
|
||||
{
|
||||
public partial class DoubleToDecimalConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
if (value == null) return string.Empty;
|
||||
|
||||
if (double.TryParse(value.ToString(), out double number))
|
||||
{
|
||||
int decimalPlaces = 2;
|
||||
if (parameter != null && int.TryParse(parameter.ToString(), out int parsedParams))
|
||||
{
|
||||
decimalPlaces = parsedParams;
|
||||
}
|
||||
|
||||
return number.ToString($"F{decimalPlaces}");
|
||||
}
|
||||
|
||||
return value.ToString() ?? "";
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace BetterLyrics.WinUI3.Converter
|
||||
FileSourceType.Local => "\uE8B7", // Folder
|
||||
FileSourceType.SMB => "\uE839", // Network
|
||||
FileSourceType.FTP => "\uE838", // Globe
|
||||
FileSourceType.WebDav => "\uE753", // Cloud
|
||||
FileSourceType.WebDAV => "\uE753", // Cloud
|
||||
_ => "\uE8B7"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,18 +3,37 @@ using System;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Converter
|
||||
{
|
||||
public class MillisecondsToFormattedTimeConverter : IValueConverter
|
||||
public partial class MillisecondsToFormattedTimeConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
if (value is int milliseconds)
|
||||
double? milliseconds = null;
|
||||
|
||||
if (value is int iVal) milliseconds = iVal;
|
||||
else if (value is double dVal) milliseconds = dVal;
|
||||
else if (value is long lVal) milliseconds = lVal;
|
||||
|
||||
if (milliseconds.HasValue)
|
||||
{
|
||||
return TimeSpan.FromMilliseconds(milliseconds).ToString(@"mm\:ss\.fff");
|
||||
}
|
||||
else if (value is double doubleMilliseconds)
|
||||
{
|
||||
return TimeSpan.FromMilliseconds(doubleMilliseconds).ToString(@"mm\:ss\.fff");
|
||||
var ts = TimeSpan.FromMilliseconds(milliseconds.Value);
|
||||
|
||||
string? format = parameter?.ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(format))
|
||||
{
|
||||
format = @"mm\:ss\.fff";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return ts.ToString(format);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return ts.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return value?.ToString() ?? "";
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using System;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Converter
|
||||
{
|
||||
public class MillisecondsToSecondsConverter : IValueConverter
|
||||
public partial class MillisecondsToSecondsConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Converter
|
||||
{
|
||||
public partial class PathToImageConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
string targetPath = PathHelper.AlbumArtPlaceholderPath;
|
||||
if (value is string path)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
targetPath = path;
|
||||
}
|
||||
}
|
||||
return new BitmapImage(new Uri(targetPath));
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using BetterLyrics.WinUI3.Extensions;
|
||||
using Microsoft.UI.Xaml.Data;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Converter
|
||||
{
|
||||
public partial class UriStringToDecodedAbsoluteUri : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
if (value is string uriString)
|
||||
{
|
||||
return uriString.ToDecodedAbsoluteUri();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Enums
|
||||
{
|
||||
public enum AutoScanInterval
|
||||
{
|
||||
Disabled,
|
||||
Every15Minutes,
|
||||
EveryHour,
|
||||
Every6Hours,
|
||||
Daily
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,6 @@
|
||||
Local,
|
||||
SMB,
|
||||
FTP,
|
||||
WebDav
|
||||
WebDAV
|
||||
}
|
||||
}
|
||||
|
||||
16
BetterLyrics.WinUI3/BetterLyrics.WinUI3/Enums/StatsRange.cs
Normal file
16
BetterLyrics.WinUI3/BetterLyrics.WinUI3/Enums/StatsRange.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Enums
|
||||
{
|
||||
public enum StatsRange
|
||||
{
|
||||
Today,
|
||||
ThisWeek,
|
||||
ThisMonth,
|
||||
ThisQuarter,
|
||||
ThisYear,
|
||||
Custom
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Extensions
|
||||
{
|
||||
public static class LyricsDataExtensions
|
||||
{
|
||||
extension(LyricsData lyricsData)
|
||||
{
|
||||
public static LyricsData GetLoadingPlaceholder()
|
||||
{
|
||||
return new LyricsData()
|
||||
{
|
||||
LyricsLines = [
|
||||
new LyricsLine
|
||||
{
|
||||
StartMs = 0,
|
||||
EndMs = (int)TimeSpan.FromMinutes(99).TotalMilliseconds,
|
||||
OriginalText = "● ● ●",
|
||||
},
|
||||
],
|
||||
LanguageCode = "N/A",
|
||||
};
|
||||
}
|
||||
|
||||
public void SetTranslatedText(LyricsData translationData, int toleranceMs = 50)
|
||||
{
|
||||
foreach (var line in lyricsData.LyricsLines)
|
||||
{
|
||||
// 在翻译歌词中查找与当前行开始时间最接近且在容忍范围内的行
|
||||
var transLine = translationData.LyricsLines
|
||||
.FirstOrDefault(t => Math.Abs(t.StartMs - line.StartMs) <= toleranceMs);
|
||||
|
||||
if (transLine != null)
|
||||
{
|
||||
// 此处 transLine.OriginalText 指翻译中的“原文”属性
|
||||
line.TranslatedText = transLine.OriginalText;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 没有匹配的翻译
|
||||
line.TranslatedText = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetPhoneticText(LyricsData phoneticData, int toleranceMs = 50)
|
||||
{
|
||||
foreach (var line in lyricsData.LyricsLines)
|
||||
{
|
||||
// 在音译歌词中查找与当前行开始时间最接近且在容忍范围内的行
|
||||
var transLine = phoneticData.LyricsLines
|
||||
.FirstOrDefault(t => Math.Abs(t.StartMs - line.StartMs) <= toleranceMs);
|
||||
|
||||
if (transLine != null)
|
||||
{
|
||||
// 此处 transLine.OriginalText 指音译中的“原文”属性
|
||||
line.PhoneticText = transLine.OriginalText;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 没有匹配的音译
|
||||
line.PhoneticText = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTranslation(string translation)
|
||||
{
|
||||
List<string> translationArr = translation.Split(StringHelper.NewLine).ToList();
|
||||
int i = 0;
|
||||
foreach (var line in lyricsData.LyricsLines)
|
||||
{
|
||||
if (i >= translationArr.Count)
|
||||
{
|
||||
line.TranslatedText = ""; // No translation available, keep empty
|
||||
}
|
||||
else
|
||||
{
|
||||
line.TranslatedText = translationArr[i];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTransliteration(string transliteration)
|
||||
{
|
||||
List<string> transliterationArr = transliteration.Split(StringHelper.NewLine).ToList();
|
||||
int i = 0;
|
||||
foreach (var line in lyricsData.LyricsLines)
|
||||
{
|
||||
if (i >= transliterationArr.Count)
|
||||
{
|
||||
line.PhoneticText = ""; // No transliteration available, keep empty
|
||||
}
|
||||
else
|
||||
{
|
||||
line.PhoneticText = transliterationArr[i];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
public LyricsLine? GetLyricsLine(double sec)
|
||||
{
|
||||
for (int i = 0; i < lyricsData.LyricsLines.Count; i++)
|
||||
{
|
||||
var line = lyricsData.LyricsLines[i];
|
||||
if (line.StartMs > sec * 1000)
|
||||
{
|
||||
return lyricsData.LyricsLines.ElementAtOrDefault(i - 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using System;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Extensions
|
||||
{
|
||||
public static class SongInfoExtensions
|
||||
{
|
||||
public static SongInfo Placeholder => new SongInfo
|
||||
public static SongInfo Placeholder => new()
|
||||
{
|
||||
Title = "N/A",
|
||||
Album = "N/A",
|
||||
@@ -30,6 +31,22 @@ namespace BetterLyrics.WinUI3.Extensions
|
||||
songInfo.Album = value;
|
||||
return songInfo;
|
||||
}
|
||||
|
||||
public PlayHistoryItem? ToPlayHistoryItem(double actualPlayedMs)
|
||||
{
|
||||
if (songInfo == null) return null;
|
||||
|
||||
return new PlayHistoryItem
|
||||
{
|
||||
Title = songInfo.Title,
|
||||
Artist = songInfo.DisplayArtists,
|
||||
Album = songInfo.Album,
|
||||
PlayerId = songInfo.PlayerId ?? "N/A",
|
||||
TotalDurationMs = songInfo.DurationMs,
|
||||
DurationPlayedMs = actualPlayedMs,
|
||||
StartedAt = DateTime.FromBinary(songInfo.StartedAt)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Extensions
|
||||
@@ -75,6 +76,18 @@ namespace BetterLyrics.WinUI3.Extensions
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public string ToDecodedAbsoluteUri()
|
||||
{
|
||||
if (string.IsNullOrEmpty(str)) return "";
|
||||
try
|
||||
{
|
||||
var u = new Uri(str);
|
||||
return u.IsFile ? u.LocalPath : System.Net.WebUtility.UrlDecode(u.AbsoluteUri);
|
||||
}
|
||||
catch { return str; }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using BetterLyrics.WinUI3.Enums;
|
||||
using BetterLyrics.WinUI3.Hooks;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Xaml;
|
||||
using SkiaSharp;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing.Imaging;
|
||||
@@ -15,6 +16,16 @@ namespace BetterLyrics.WinUI3.Helper
|
||||
{
|
||||
public static class ColorHelper
|
||||
{
|
||||
public static Color GetSystemAccentColor()
|
||||
{
|
||||
if (Application.Current.Resources.TryGetValue("SystemAccentColor", out var resource) &&
|
||||
resource is Color uiColor)
|
||||
{
|
||||
return uiColor;
|
||||
}
|
||||
return Color.FromArgb(255, 0, 120, 215);
|
||||
}
|
||||
|
||||
public static ElementTheme GetElementThemeFromBackgroundColor(Color backgroundColor)
|
||||
{
|
||||
// 计算亮度(YIQ公式)
|
||||
|
||||
@@ -5,8 +5,11 @@ using BetterLyrics.WinUI3.Extensions;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Serialization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Ude;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Helper
|
||||
@@ -27,6 +30,18 @@ namespace BetterLyrics.WinUI3.Helper
|
||||
return Encoding.GetEncoding(encoding);
|
||||
}
|
||||
|
||||
public static async Task CopyFileAsync(string sourcePath, string destinationPath)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(destinationPath);
|
||||
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
using (var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
using (var destinationStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await sourceStream.CopyToAsync(destinationStream);
|
||||
}
|
||||
}
|
||||
|
||||
public static string SanitizeFileName(string fileName, char replacement = '_')
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
@@ -86,5 +101,15 @@ namespace BetterLyrics.WinUI3.Helper
|
||||
".wav", ".aiff", ".aif", ".pcm", ".cda", ".dsf", ".dff", ".au", ".snd",
|
||||
".mid", ".midi", ".mod", ".xm", ".it", ".s3m"
|
||||
};
|
||||
|
||||
public static readonly string[] LyricExtensions =
|
||||
Enum.GetValues(typeof(LyricsSearchProvider)).Cast<LyricsSearchProvider>()
|
||||
.Where(x => x.IsLocal())
|
||||
.Select(x => x.GetLyricsFormat())
|
||||
.Where(x => x != LyricsFormat.NotSpecified)
|
||||
.Select(x => x.ToFileExtension())
|
||||
.ToArray();
|
||||
|
||||
public static readonly HashSet<string> AllSupportedExtensions = new(MusicExtensions.Union(LyricExtensions));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Collections.ObjectModel;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
|
||||
public static class FolderTreeBuilder
|
||||
{
|
||||
public static ObservableCollection<FolderNode> Build(List<ExtendedTrack> tracks, List<MediaFolder> folderConfigs)
|
||||
{
|
||||
var rootNodes = new ObservableCollection<FolderNode>();
|
||||
|
||||
// 按 MediaFolderId 分组
|
||||
var folderGroups = tracks.GroupBy(t => t.MediaFolderId);
|
||||
|
||||
foreach (var group in folderGroups)
|
||||
{
|
||||
var config = folderConfigs.FirstOrDefault(f => f.Id == group.Key);
|
||||
if (config == null) continue;
|
||||
|
||||
string baseUri = config.GetStandardUri().AbsoluteUri.TrimEnd('/');
|
||||
|
||||
var rootNode = new FolderNode
|
||||
{
|
||||
SourceType = config.SourceType,
|
||||
FolderName = config.Name ?? config.ConnectionSummary, // 显示用户自定义的名字
|
||||
MediaFolderId = group.Key,
|
||||
FolderPath = baseUri,
|
||||
IsExpanded = true
|
||||
};
|
||||
|
||||
foreach (var track in group)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!track.Uri.StartsWith(baseUri)) continue; // 防御性编程
|
||||
|
||||
string relativePart = track.Uri.Substring(baseUri.Length);
|
||||
|
||||
var segments = relativePart
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => System.Net.WebUtility.UrlDecode(s))
|
||||
.ToArray();
|
||||
|
||||
if (segments.Length > 1) // 长度大于1说明在子文件夹里
|
||||
{
|
||||
var folderSegments = segments.Take(segments.Length - 1).ToArray();
|
||||
CreateFolderStructure(rootNode, folderSegments, baseUri);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
rootNodes.Add(rootNode);
|
||||
}
|
||||
|
||||
return rootNodes;
|
||||
}
|
||||
|
||||
private static void CreateFolderStructure(FolderNode parent, string[] segments, string rootBaseUri)
|
||||
{
|
||||
var current = parent;
|
||||
string currentFullPath = parent.FolderPath;
|
||||
|
||||
foreach (var segmentName in segments)
|
||||
{
|
||||
var existingChild = current.SubFolders.FirstOrDefault(f => f.FolderName == segmentName);
|
||||
|
||||
currentFullPath += "/" + System.Net.WebUtility.UrlEncode(segmentName);
|
||||
|
||||
if (existingChild == null)
|
||||
{
|
||||
var newFolder = new FolderNode
|
||||
{
|
||||
FolderName = segmentName,
|
||||
FolderPath = currentFullPath, // 存完整的 URI
|
||||
MediaFolderId = parent.MediaFolderId
|
||||
};
|
||||
current.SubFolders.Add(newFolder);
|
||||
current = newFolder;
|
||||
}
|
||||
else
|
||||
{
|
||||
current = existingChild;
|
||||
currentFullPath = existingChild.FolderPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ColorThiefDotNet;
|
||||
using CommunityToolkit.WinUI.Helpers;
|
||||
using Impressionist.Abstractions;
|
||||
using Impressionist.Implementations;
|
||||
using System;
|
||||
@@ -50,7 +51,29 @@ namespace BetterLyrics.WinUI3.Helper
|
||||
return paletteResult;
|
||||
}
|
||||
|
||||
public static async Task<Dictionary<Vector3, int>> GetPixelColor(BitmapDecoder bitmapDecoder)
|
||||
public static List<Windows.UI.Color> GenerateChartColors(Windows.UI.Color baseColor, int count)
|
||||
{
|
||||
List<Windows.UI.Color> results = [];
|
||||
|
||||
var baseHsl = baseColor.ToHsl();
|
||||
double baseHue = baseHsl.H;
|
||||
double baseSaturation = baseHsl.S;
|
||||
double baseBrightness = baseHsl.L;
|
||||
|
||||
double step = 360.0 / count;
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
double newHue = (baseHue + (step * i)) % 360;
|
||||
|
||||
Windows.UI.Color newColor = CommunityToolkit.WinUI.Helpers.ColorHelper.FromHsl(newHue, baseSaturation, baseBrightness);
|
||||
results.Add(newColor);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<Vector3, int>> GetPixelColor(BitmapDecoder bitmapDecoder)
|
||||
{
|
||||
var pixelDataProvider = await bitmapDecoder.GetPixelDataAsync();
|
||||
var pixels = pixelDataProvider.DetachPixelData();
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace BetterLyrics.WinUI3.Helper
|
||||
public static string SaltPlayerForWindowsLogoPath => Path.Combine(AssetsFolder, "SaltPlayerForWindows.png");
|
||||
public static string MoeKoeMusicLogoPath => Path.Combine(AssetsFolder, "MoeKoeMusic.png");
|
||||
public static string Listen1LogoPath => Path.Combine(AssetsFolder, "Listen1.png");
|
||||
public static string OriginalSoundHQPlayerLogoPath => Path.Combine(AssetsFolder, "OriginalSoundHQPlayer.png");
|
||||
public static string UnknownPlayerLogoPath => Path.Combine(AssetsFolder, "Question.png");
|
||||
|
||||
public static string LogDirectory => Path.Combine(CacheFolder, "logs");
|
||||
@@ -54,8 +55,11 @@ namespace BetterLyrics.WinUI3.Helper
|
||||
|
||||
public static string AlbumArtCacheDirectory => Path.Combine(CacheFolder, "album-art");
|
||||
public static string iTunesAlbumArtCacheDirectory => Path.Combine(AlbumArtCacheDirectory, "itunes");
|
||||
public static string LocalAlbumArtCacheDirectory => Path.Combine(AlbumArtCacheDirectory, "local");
|
||||
|
||||
public static string PlayQueuePath => Path.Combine(CacheFolder, "play-queue.m3u");
|
||||
public static string PlayQueuePath => Path.Combine(LocalFolder, "play-queue.m3u");
|
||||
public static string PlayHistoryPath => Path.Combine(LocalFolder, "play-history.db");
|
||||
public static string FilesIndexPath => Path.Combine(LocalFolder, "files-index.db");
|
||||
|
||||
public static void EnsureDirectories()
|
||||
{
|
||||
@@ -75,6 +79,8 @@ namespace BetterLyrics.WinUI3.Helper
|
||||
Directory.CreateDirectory(LocalTtmlCacheDirectory);
|
||||
|
||||
Directory.CreateDirectory(iTunesAlbumArtCacheDirectory);
|
||||
Directory.CreateDirectory(LocalAlbumArtCacheDirectory);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Helper
|
||||
{
|
||||
public static class PlayerIDHelper
|
||||
public static class PlayerIdHelper
|
||||
{
|
||||
private static readonly List<string> neteaseFamilyRegex =
|
||||
[
|
||||
@@ -25,64 +25,66 @@ namespace BetterLyrics.WinUI3.Helper
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsLXMusic(string? id) => id is PlayerID.LXMusic or PlayerID.LXMusicPortable;
|
||||
public static bool IsLXMusic(string? id) => id is PlayerId.LXMusic or PlayerId.LXMusicPortable;
|
||||
|
||||
public static bool IsAppleMusic(string? id) => id is PlayerID.AppleMusic or PlayerID.AppleMusicAlternative;
|
||||
public static bool IsAppleMusic(string? id) => id is PlayerId.AppleMusic or PlayerId.AppleMusicAlternative;
|
||||
|
||||
public static bool IsBetterLyrics(string? id) => id is PlayerID.BetterLyrics or PlayerID.BetterLyricsDebug;
|
||||
public static bool IsBetterLyrics(string? id) => id is PlayerId.BetterLyrics or PlayerId.BetterLyricsDebug;
|
||||
|
||||
public static string? GetDisplayName(string? id) => id switch
|
||||
{
|
||||
PlayerID.Spotify => PlayerName.Spotify,
|
||||
PlayerID.AppleMusic => PlayerName.AppleMusic,
|
||||
PlayerID.iTunes => PlayerName.iTunes,
|
||||
PlayerID.KugouMusic => PlayerName.KugouMusic,
|
||||
PlayerID.NetEaseCloudMusic => PlayerName.NetEaseCloudMusic,
|
||||
PlayerID.QQMusic => PlayerName.QQMusic,
|
||||
PlayerID.LXMusic => PlayerName.LXMusic,
|
||||
PlayerID.LXMusicPortable => PlayerName.LXMusicPortable,
|
||||
PlayerID.MediaPlayerWindows11 => PlayerName.MediaPlayerWindows11,
|
||||
PlayerID.AIMP => PlayerName.AIMP,
|
||||
PlayerID.Foobar2000 => PlayerName.Foobar2000,
|
||||
PlayerID.MusicBee => PlayerName.MusicBee,
|
||||
PlayerID.PotPlayer => PlayerName.PotPlayer,
|
||||
PlayerID.Chrome => PlayerName.Chrome,
|
||||
PlayerID.Edge => PlayerName.Edge,
|
||||
PlayerID.BetterLyrics => PlayerName.BetterLyrics,
|
||||
PlayerID.BetterLyricsDebug => PlayerName.BetterLyricsDebug,
|
||||
PlayerID.SaltPlayerForWindowsMS => PlayerName.SaltPlayerForWindowsMS,
|
||||
PlayerID.SaltPlayerForWindowsSteam => PlayerName.SaltPlayerForWindowsSteam,
|
||||
PlayerID.MoeKoeMusic => PlayerName.MoeKoeMusic,
|
||||
PlayerID.MoeKoeMusicAlternative => PlayerName.MoeKoeMusic,
|
||||
PlayerID.Listen1 => PlayerName.Listen1,
|
||||
PlayerId.Spotify => PlayerName.Spotify,
|
||||
PlayerId.AppleMusic => PlayerName.AppleMusic,
|
||||
PlayerId.iTunes => PlayerName.iTunes,
|
||||
PlayerId.KugouMusic => PlayerName.KugouMusic,
|
||||
PlayerId.NetEaseCloudMusic => PlayerName.NetEaseCloudMusic,
|
||||
PlayerId.QQMusic => PlayerName.QQMusic,
|
||||
PlayerId.LXMusic => PlayerName.LXMusic,
|
||||
PlayerId.LXMusicPortable => PlayerName.LXMusicPortable,
|
||||
PlayerId.MediaPlayerWindows11 => PlayerName.MediaPlayerWindows11,
|
||||
PlayerId.AIMP => PlayerName.AIMP,
|
||||
PlayerId.Foobar2000 => PlayerName.Foobar2000,
|
||||
PlayerId.MusicBee => PlayerName.MusicBee,
|
||||
PlayerId.PotPlayer => PlayerName.PotPlayer,
|
||||
PlayerId.Chrome => PlayerName.Chrome,
|
||||
PlayerId.Edge => PlayerName.Edge,
|
||||
PlayerId.BetterLyrics => PlayerName.BetterLyrics,
|
||||
PlayerId.BetterLyricsDebug => PlayerName.BetterLyricsDebug,
|
||||
PlayerId.SaltPlayerForWindowsMS => PlayerName.SaltPlayerForWindowsMS,
|
||||
PlayerId.SaltPlayerForWindowsSteam => PlayerName.SaltPlayerForWindowsSteam,
|
||||
PlayerId.MoeKoeMusic => PlayerName.MoeKoeMusic,
|
||||
PlayerId.MoeKoeMusicAlternative => PlayerName.MoeKoeMusic,
|
||||
PlayerId.Listen1 => PlayerName.Listen1,
|
||||
PlayerId.OriginalSoundHQPlayer => PlayerName.OriginalSoundHQPlayer,
|
||||
_ => id,
|
||||
};
|
||||
|
||||
public static string GetLogoPath(string? id) => id switch
|
||||
{
|
||||
PlayerID.Spotify => PathHelper.SpotifyLogoPath,
|
||||
PlayerID.AppleMusic => PathHelper.AppleMusicLogoPath,
|
||||
PlayerID.AppleMusicAlternative => PathHelper.AppleMusicLogoPath,
|
||||
PlayerID.iTunes => PathHelper.iTunesLogoPath,
|
||||
PlayerID.KugouMusic => PathHelper.KugouMusicLogoPath,
|
||||
PlayerID.NetEaseCloudMusic => PathHelper.NetEaseCloudMusicLogoPath,
|
||||
PlayerID.QQMusic => PathHelper.QQMusicLogoPath,
|
||||
PlayerID.LXMusic => PathHelper.LXMusicLogoPath,
|
||||
PlayerID.LXMusicPortable => PathHelper.LXMusicLogoPath,
|
||||
PlayerID.MediaPlayerWindows11 => PathHelper.MediaPlayerWindows11LogoPath,
|
||||
PlayerID.AIMP => PathHelper.AIMPLogoPath,
|
||||
PlayerID.Foobar2000 => PathHelper.Foobar2000LogoPath,
|
||||
PlayerID.MusicBee => PathHelper.MusicBeeLogoPath,
|
||||
PlayerID.PotPlayer => PathHelper.PotPlayerLogoPath,
|
||||
PlayerID.Chrome => PathHelper.ChromeLogoPath,
|
||||
PlayerID.Edge => PathHelper.EdgeLogoPath,
|
||||
PlayerID.BetterLyrics => PathHelper.LogoPath,
|
||||
PlayerID.BetterLyricsDebug => PathHelper.LogoPath,
|
||||
PlayerID.SaltPlayerForWindowsMS => PathHelper.SaltPlayerForWindowsLogoPath,
|
||||
PlayerID.SaltPlayerForWindowsSteam => PathHelper.SaltPlayerForWindowsLogoPath,
|
||||
PlayerID.MoeKoeMusic => PathHelper.MoeKoeMusicLogoPath,
|
||||
PlayerID.MoeKoeMusicAlternative => PathHelper.MoeKoeMusicLogoPath,
|
||||
PlayerID.Listen1 => PathHelper.Listen1LogoPath,
|
||||
PlayerId.Spotify => PathHelper.SpotifyLogoPath,
|
||||
PlayerId.AppleMusic => PathHelper.AppleMusicLogoPath,
|
||||
PlayerId.AppleMusicAlternative => PathHelper.AppleMusicLogoPath,
|
||||
PlayerId.iTunes => PathHelper.iTunesLogoPath,
|
||||
PlayerId.KugouMusic => PathHelper.KugouMusicLogoPath,
|
||||
PlayerId.NetEaseCloudMusic => PathHelper.NetEaseCloudMusicLogoPath,
|
||||
PlayerId.QQMusic => PathHelper.QQMusicLogoPath,
|
||||
PlayerId.LXMusic => PathHelper.LXMusicLogoPath,
|
||||
PlayerId.LXMusicPortable => PathHelper.LXMusicLogoPath,
|
||||
PlayerId.MediaPlayerWindows11 => PathHelper.MediaPlayerWindows11LogoPath,
|
||||
PlayerId.AIMP => PathHelper.AIMPLogoPath,
|
||||
PlayerId.Foobar2000 => PathHelper.Foobar2000LogoPath,
|
||||
PlayerId.MusicBee => PathHelper.MusicBeeLogoPath,
|
||||
PlayerId.PotPlayer => PathHelper.PotPlayerLogoPath,
|
||||
PlayerId.Chrome => PathHelper.ChromeLogoPath,
|
||||
PlayerId.Edge => PathHelper.EdgeLogoPath,
|
||||
PlayerId.BetterLyrics => PathHelper.LogoPath,
|
||||
PlayerId.BetterLyricsDebug => PathHelper.LogoPath,
|
||||
PlayerId.SaltPlayerForWindowsMS => PathHelper.SaltPlayerForWindowsLogoPath,
|
||||
PlayerId.SaltPlayerForWindowsSteam => PathHelper.SaltPlayerForWindowsLogoPath,
|
||||
PlayerId.MoeKoeMusic => PathHelper.MoeKoeMusicLogoPath,
|
||||
PlayerId.MoeKoeMusicAlternative => PathHelper.MoeKoeMusicLogoPath,
|
||||
PlayerId.Listen1 => PathHelper.Listen1LogoPath,
|
||||
PlayerId.OriginalSoundHQPlayer => PathHelper.OriginalSoundHQPlayerLogoPath,
|
||||
_ => PathHelper.UnknownPlayerLogoPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models.FileSystem
|
||||
namespace BetterLyrics.WinUI3.Helper
|
||||
{
|
||||
public class StreamFileAbstraction : TagLib.File.IFileAbstraction
|
||||
{
|
||||
@@ -9,7 +9,7 @@ namespace BetterLyrics.WinUI3.Models.FileSystem
|
||||
private readonly Stream _stream;
|
||||
private readonly bool _closeStreamOnDispose;
|
||||
|
||||
public StreamFileAbstraction(string path, Stream stream, bool closeStreamOnDispose = false)
|
||||
public StreamFileAbstraction(string path, Stream? stream, bool closeStreamOnDispose = false)
|
||||
{
|
||||
_name = Path.GetFileName(path);
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Helper
|
||||
{
|
||||
public static class WebDavProbeHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 自动检测目标主机是 HTTP 还是 HTTPS
|
||||
/// </summary>
|
||||
/// <returns>返回 "https" 或 "http",如果都连不上返回 null</returns>
|
||||
public static async Task<string?> DetectSchemeAsync(string host, int port, string? path, string? user, string? pwd)
|
||||
{
|
||||
if (port == 443) return "https";
|
||||
if (port == 80) return "http";
|
||||
|
||||
// 忽略 SSL 证书错误,因为很多 NAS 是自签名的
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true,
|
||||
UseProxy = false
|
||||
};
|
||||
|
||||
// 设置认证
|
||||
if (!string.IsNullOrEmpty(user) && !string.IsNullOrEmpty(pwd))
|
||||
{
|
||||
handler.Credentials = new NetworkCredential(user, pwd);
|
||||
handler.PreAuthenticate = true;
|
||||
}
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
client.Timeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
if (await ProbeUrlAsync(client, "https", host, port, path))
|
||||
{
|
||||
return "https";
|
||||
}
|
||||
|
||||
if (await ProbeUrlAsync(client, "http", host, port, path))
|
||||
{
|
||||
return "http";
|
||||
}
|
||||
|
||||
// 都失败
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<bool> ProbeUrlAsync(HttpClient client, string scheme, string host, int port, string? path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uriBuilder = new UriBuilder(scheme, host, port, path);
|
||||
|
||||
// 使用 PROPFIND 方法,且 Depth 为 0,只检测根节点是否存在,不拉取列表
|
||||
var request = new HttpRequestMessage(new HttpMethod("PROPFIND"), uriBuilder.Uri);
|
||||
request.Headers.Add("Depth", "0");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
return response.StatusCode != HttpStatusCode.BadRequest;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,20 @@ namespace BetterLyrics.WinUI3.Hooks
|
||||
|
||||
static SystemVolumeHook()
|
||||
{
|
||||
_deviceEnumerator = new MMDeviceEnumerator();
|
||||
_defaultDevice = _deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
||||
|
||||
if (_defaultDevice != null)
|
||||
try
|
||||
{
|
||||
_defaultDevice.AudioEndpointVolume.OnVolumeNotification += AudioEndpointVolume_OnVolumeNotification;
|
||||
_deviceEnumerator = new MMDeviceEnumerator();
|
||||
// 找不到设备会抛出异常,在这里截获它
|
||||
_defaultDevice = _deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
|
||||
|
||||
if (_defaultDevice != null)
|
||||
{
|
||||
_defaultDevice.AudioEndpointVolume.OnVolumeNotification += AudioEndpointVolume_OnVolumeNotification;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_defaultDevice = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using FlaUI.Core.EventHandlers;
|
||||
using FlaUI.UIA3;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Threading;
|
||||
|
||||
@@ -186,7 +187,8 @@ namespace BetterLyrics.WinUI3.Hooks
|
||||
|
||||
if (width < 20) return Rectangle.Empty;
|
||||
|
||||
return new Rectangle(finalLeft, taskbarRect.Top, width, taskbarRect.Height);
|
||||
var finalRect = new Rectangle(finalLeft, taskbarRect.Top, width, taskbarRect.Height);
|
||||
return finalRect;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models.Db
|
||||
{
|
||||
public partial class FilesIndexDbContext : DbContext
|
||||
{
|
||||
public FilesIndexDbContext(DbContextOptions<FilesIndexDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<FilesIndexItem> FilesIndex { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models.Db
|
||||
{
|
||||
public partial class PlayHistoryDbContext : DbContext
|
||||
{
|
||||
public PlayHistoryDbContext(DbContextOptions<PlayHistoryDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<PlayHistoryItem> PlayHistory { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,200 @@
|
||||
using BetterLyrics.WinUI3.Models.FileSystem;
|
||||
using ATL;
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models
|
||||
{
|
||||
public class ExtendedTrack : ATL.Track
|
||||
public class ExtendedTrack
|
||||
{
|
||||
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 string Uri { get; private set; } = "";
|
||||
|
||||
public string? RawLyrics { get; set; }
|
||||
public string? LocalAlbumArtPath { get; set; }
|
||||
public byte[]? AlbumArtByteArray { get; set; }
|
||||
|
||||
public string ParentFolderName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(Uri)) return "";
|
||||
try
|
||||
{
|
||||
// 使用 Uri Segments 安全获取倒数第二层 (文件夹名)
|
||||
// Segments 示例: "/", "Music/", "Artist/", "Song.mp3"
|
||||
var u = new System.Uri(Uri);
|
||||
if (u.Segments.Length > 1)
|
||||
{
|
||||
// 取倒数第二个 segment (如果是文件)
|
||||
// 注意处理末尾斜杠
|
||||
string folder = u.Segments[u.Segments.Length - 2];
|
||||
return System.Net.WebUtility.UrlDecode(folder.TrimEnd('/', '\\'));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string ParentFolderPath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(Uri)) return "";
|
||||
try
|
||||
{
|
||||
var u = new System.Uri(Uri);
|
||||
if (u.IsFile)
|
||||
{
|
||||
// 本地文件:返回目录路径 C:\Music
|
||||
return System.IO.Path.GetDirectoryName(u.LocalPath) ?? "";
|
||||
}
|
||||
else
|
||||
{
|
||||
// 远程文件:返回去掉文件名的 URI
|
||||
// new Uri(u, ".") 表示当前目录
|
||||
return new System.Uri(u, ".").AbsoluteUri;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string FileName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(Uri)) return "";
|
||||
try
|
||||
{
|
||||
var u = new System.Uri(Uri);
|
||||
if (u.IsFile) return System.IO.Path.GetFileName(u.LocalPath);
|
||||
|
||||
// 远程文件:获取 AbsolutePath 的最后一段并解码
|
||||
// 例如: /Music/My%20Song.mp3 -> My Song.mp3
|
||||
string rawName = System.IO.Path.GetFileName(u.AbsolutePath);
|
||||
return System.Net.WebUtility.UrlDecode(rawName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return System.IO.Path.GetFileName(Uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
public string MediaFolderId { get; set; } = "";
|
||||
|
||||
public string Title { get; set; } = "";
|
||||
public string Artist { get; set; } = "";
|
||||
public string Album { get; set; } = "";
|
||||
public int? Year { get; set; }
|
||||
public int Bitrate { get; set; }
|
||||
public double SampleRate { get; set; }
|
||||
public int BitDepth { get; set; }
|
||||
public int Duration { get; set; }
|
||||
public string AudioFormatName { get; set; } = "";
|
||||
public string AudioFormatShortName { get; set; } = "";
|
||||
public string Encoder { get; set; } = "";
|
||||
|
||||
|
||||
public ExtendedTrack() : base() { }
|
||||
|
||||
public ExtendedTrack(string path) : base(path)
|
||||
public ExtendedTrack(string decodedUriString) : base()
|
||||
{
|
||||
Path = path;
|
||||
string atlPath = decodedUriString;
|
||||
try
|
||||
{
|
||||
var u = new Uri(decodedUriString);
|
||||
Uri = u.AbsoluteUri;
|
||||
if (u.IsFile) atlPath = u.LocalPath;
|
||||
}
|
||||
catch { }
|
||||
|
||||
// 用于本地文件
|
||||
var track = new Track(atlPath);
|
||||
SetFromTrack(track);
|
||||
}
|
||||
|
||||
public ExtendedTrack(string path, Stream stream) : base(stream, System.IO.Path.GetExtension(path))
|
||||
public ExtendedTrack(FilesIndexItem? entity, Stream? stream = null) : base()
|
||||
{
|
||||
Path = path;
|
||||
SetRawLyrics(new StreamFileAbstraction(path, stream));
|
||||
if (entity == null) return;
|
||||
|
||||
this.MediaFolderId = entity.MediaFolderId;
|
||||
this.Uri = entity.Uri;
|
||||
|
||||
this.Title = entity.Title;
|
||||
this.Artist = entity.Artists;
|
||||
this.Album = entity.Album;
|
||||
this.Year = entity.Year;
|
||||
this.Bitrate = entity.Bitrate;
|
||||
this.SampleRate = entity.SampleRate;
|
||||
this.BitDepth = entity.BitDepth;
|
||||
|
||||
this.Duration = entity.Duration;
|
||||
|
||||
this.AudioFormatName = entity.AudioFormatName;
|
||||
this.AudioFormatShortName = entity.AudioFormatShortName;
|
||||
|
||||
this.Encoder = entity.Encoder;
|
||||
|
||||
this.RawLyrics = entity.EmbeddedLyrics;
|
||||
this.LocalAlbumArtPath = entity.LocalAlbumArtPath;
|
||||
|
||||
if (stream != null)
|
||||
{
|
||||
var track = new Track(stream, Path.GetExtension(FileName));
|
||||
SetFromTrack(track);
|
||||
SetRawLyrics(new StreamFileAbstraction(Uri, stream));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetFromTrack(Track? track)
|
||||
{
|
||||
if (track == null) return;
|
||||
|
||||
this.Title = track.Title;
|
||||
this.Artist = track.Artist;
|
||||
this.Album = track.Album;
|
||||
this.Year = track.Year;
|
||||
this.Bitrate = track.Bitrate;
|
||||
this.SampleRate = track.SampleRate;
|
||||
this.BitDepth = track.BitDepth;
|
||||
|
||||
this.Duration = track.Duration;
|
||||
|
||||
this.AudioFormatName = track.AudioFormat.Name;
|
||||
this.AudioFormatShortName = track.AudioFormat.ShortName;
|
||||
|
||||
this.Encoder = track.Encoder;
|
||||
|
||||
this.AlbumArtByteArray = null;
|
||||
|
||||
if (track.EmbeddedPictures != null && track.EmbeddedPictures.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var validPics = track.EmbeddedPictures.Where(p => p != null).ToList();
|
||||
|
||||
if (validPics.Count > 0)
|
||||
{
|
||||
var cover = validPics.FirstOrDefault(p => p.PicType == PictureInfo.PIC_TYPE.Front);
|
||||
|
||||
if (cover == null)
|
||||
{
|
||||
cover = validPics.First();
|
||||
}
|
||||
|
||||
this.AlbumArtByteArray = cover.PictureData;
|
||||
}
|
||||
}
|
||||
catch (Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private void SetRawLyrics(StreamFileAbstraction streamFileAbstraction)
|
||||
@@ -34,4 +206,4 @@ namespace BetterLyrics.WinUI3.Models
|
||||
catch (Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using FluentFTP;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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 仅仅是 IP,remotePath 才是路径
|
||||
_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();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
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() { }
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using SMBLibrary;
|
||||
using SMBLibrary.Client;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using System;
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models
|
||||
{
|
||||
[Index(nameof(MediaFolderId))] // 普通索引
|
||||
[Index(nameof(ParentUri))] // 普通索引
|
||||
[Index(nameof(Uri), IsUnique = true)] // 唯一索引
|
||||
public class FilesIndexItem
|
||||
{
|
||||
[Key] // 主键
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)] // 明确指定为自增 (Identity)
|
||||
public int Id { get; set; }
|
||||
|
||||
// 关联到 MediaFolder.Id
|
||||
// 注意:作为索引列,必须限制长度,否则 SQL Server 会报错 (索引最大900字节)
|
||||
[MaxLength(450)]
|
||||
public string MediaFolderId { get; set; }
|
||||
|
||||
// 存储父文件夹的标准 URI
|
||||
// 允许为空
|
||||
[MaxLength(450)]
|
||||
public string? ParentUri { get; set; }
|
||||
|
||||
// 唯一索引列
|
||||
// 必须限制长度。450字符 * 2字节/字符 = 900字节 (正好卡在 SQL Server 限制内)
|
||||
[Required]
|
||||
[MaxLength(450)]
|
||||
public string Uri { get; set; }
|
||||
|
||||
public string FileName { get; set; } = "";
|
||||
|
||||
public bool IsDirectory { get; set; }
|
||||
|
||||
public long FileSize { get; set; }
|
||||
|
||||
public DateTime? LastModified { get; set; }
|
||||
|
||||
// 下面的元数据字段通常不需要索引,可以使用 MaxLength 稍微优化空间,
|
||||
// 或者直接留空(默认为 nvarchar(max))
|
||||
public string Title { get; set; } = "";
|
||||
public string Artists { get; set; } = "";
|
||||
public string Album { get; set; } = "";
|
||||
public int? Year { get; set; }
|
||||
public int Bitrate { get; set; }
|
||||
public double SampleRate { get; set; }
|
||||
public int BitDepth { get; set; }
|
||||
public int Duration { get; set; }
|
||||
|
||||
[MaxLength(50)] // 格式名称通常很短,限制一下是个好习惯
|
||||
public string AudioFormatName { get; set; } = "";
|
||||
|
||||
[MaxLength(20)]
|
||||
public string AudioFormatShortName { get; set; } = "";
|
||||
|
||||
public string Encoder { get; set; } = "";
|
||||
|
||||
// 歌词可能会很长,保留默认的 nvarchar(max) 即可
|
||||
public string? EmbeddedLyrics { get; set; }
|
||||
|
||||
public string? LocalAlbumArtPath { get; set; }
|
||||
|
||||
public bool IsMetadataParsed { get; set; }
|
||||
}
|
||||
}
|
||||
24
BetterLyrics.WinUI3/BetterLyrics.WinUI3/Models/FolderNode.cs
Normal file
24
BetterLyrics.WinUI3/BetterLyrics.WinUI3/Models/FolderNode.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models
|
||||
{
|
||||
public partial class FolderNode : ObservableObject
|
||||
{
|
||||
public FileSourceType SourceType { get; set; } = FileSourceType.Local;
|
||||
|
||||
public string FolderName { get; set; } = "";
|
||||
|
||||
public string FolderPath { get; set; } = "";
|
||||
|
||||
public string MediaFolderId { get; set; } = "";
|
||||
|
||||
public ObservableCollection<FolderNode> SubFolders { get; set; } = new();
|
||||
|
||||
[ObservableProperty] public partial bool IsExpanded { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -29,84 +29,6 @@ namespace BetterLyrics.WinUI3.Models
|
||||
LyricsLines = lyricsLines;
|
||||
}
|
||||
|
||||
public void SetTranslatedText(LyricsData translationData, int toleranceMs = 50)
|
||||
{
|
||||
foreach (var line in LyricsLines)
|
||||
{
|
||||
// 在翻译歌词中查找与当前行开始时间最接近且在容忍范围内的行
|
||||
var transLine = translationData.LyricsLines
|
||||
.FirstOrDefault(t => Math.Abs(t.StartMs - line.StartMs) <= toleranceMs);
|
||||
|
||||
if (transLine != null)
|
||||
{
|
||||
// 此处 transLine.OriginalText 指翻译中的“原文”属性
|
||||
line.TranslatedText = transLine.OriginalText;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 没有匹配的翻译
|
||||
line.TranslatedText = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetPhoneticText(LyricsData phoneticData, int toleranceMs = 50)
|
||||
{
|
||||
foreach (var line in LyricsLines)
|
||||
{
|
||||
// 在音译歌词中查找与当前行开始时间最接近且在容忍范围内的行
|
||||
var transLine = phoneticData.LyricsLines
|
||||
.FirstOrDefault(t => Math.Abs(t.StartMs - line.StartMs) <= toleranceMs);
|
||||
|
||||
if (transLine != null)
|
||||
{
|
||||
// 此处 transLine.OriginalText 指音译中的“原文”属性
|
||||
line.PhoneticText = transLine.OriginalText;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 没有匹配的音译
|
||||
line.PhoneticText = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTranslation(string translation)
|
||||
{
|
||||
List<string> translationArr = translation.Split(StringHelper.NewLine).ToList();
|
||||
int i = 0;
|
||||
foreach (var line in LyricsLines)
|
||||
{
|
||||
if (i >= translationArr.Count)
|
||||
{
|
||||
line.TranslatedText = ""; // No translation available, keep empty
|
||||
}
|
||||
else
|
||||
{
|
||||
line.TranslatedText = translationArr[i];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTransliteration(string transliteration)
|
||||
{
|
||||
List<string> transliterationArr = transliteration.Split(StringHelper.NewLine).ToList();
|
||||
int i = 0;
|
||||
foreach (var line in LyricsLines)
|
||||
{
|
||||
if (i >= transliterationArr.Count)
|
||||
{
|
||||
line.PhoneticText = ""; // No transliteration available, keep empty
|
||||
}
|
||||
else
|
||||
{
|
||||
line.PhoneticText = transliterationArr[i];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
public static LyricsData GetNotfoundPlaceholder()
|
||||
{
|
||||
return new LyricsData([new LyricsLine
|
||||
@@ -117,34 +39,5 @@ namespace BetterLyrics.WinUI3.Models
|
||||
}]);
|
||||
}
|
||||
|
||||
public static LyricsData GetLoadingPlaceholder()
|
||||
{
|
||||
return new LyricsData()
|
||||
{
|
||||
LyricsLines = [
|
||||
new LyricsLine
|
||||
{
|
||||
StartMs = 0,
|
||||
EndMs = (int)TimeSpan.FromMinutes(99).TotalMilliseconds,
|
||||
OriginalText = "● ● ●",
|
||||
},
|
||||
],
|
||||
LanguageCode = "N/A",
|
||||
};
|
||||
}
|
||||
|
||||
public LyricsLine? GetLyricsLine(double sec)
|
||||
{
|
||||
for (int i = 0; i < LyricsLines.Count; i++)
|
||||
{
|
||||
var line = LyricsLines[i];
|
||||
if (line.StartMs > sec * 1000)
|
||||
{
|
||||
return LyricsLines.ElementAtOrDefault(i - 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// 2025/6/23 by Zhe Fang
|
||||
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models.FileSystem;
|
||||
using BetterLyrics.WinUI3.Services.FileSystemService;
|
||||
using BetterLyrics.WinUI3.Services.FileSystemService.Providers;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models
|
||||
{
|
||||
@@ -14,32 +15,83 @@ namespace BetterLyrics.WinUI3.Models
|
||||
[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))]
|
||||
[NotifyPropertyChangedFor(nameof(UriString))]
|
||||
public partial FileSourceType SourceType { get; set; } = FileSourceType.Local;
|
||||
|
||||
[ObservableProperty][NotifyPropertyChangedRecipients] public partial string Name { get; set; }
|
||||
|
||||
[ObservableProperty] public partial string UserName { get; set; }
|
||||
// 连接属性
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(UriString))] public partial string UserName { get; set; }
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(UriString))] public partial string UriScheme { get; set; }
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(UriString))] public partial string UriHost { get; set; }
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(UriString))] public partial int UriPort { get; set; } = -1;
|
||||
|
||||
[ObservableProperty] public partial int Port { get; set; } = -1;
|
||||
[JsonPropertyName("Path")]
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedRecipients]
|
||||
[NotifyPropertyChangedFor(nameof(ConnectionSummary))]
|
||||
[NotifyPropertyChangedFor(nameof(UriString))]
|
||||
public partial string UriPath { get; set; }
|
||||
|
||||
[JsonIgnore] public string Password { get; set; }
|
||||
|
||||
[JsonIgnore] public bool IsLocal => SourceType == FileSourceType.Local;
|
||||
|
||||
[ObservableProperty][NotifyPropertyChangedRecipients] public partial bool IsProcessing { get; set; } = false;
|
||||
[ObservableProperty] public partial double IndexingProgress { get; set; } = 0;
|
||||
[ObservableProperty] public partial string StatusText { get; set; } = "";
|
||||
[ObservableProperty] public partial InfoBarSeverity StatusSeverity { get; set; } = InfoBarSeverity.Informational;
|
||||
|
||||
[ObservableProperty][NotifyPropertyChangedRecipients] public partial DateTime? LastSyncTime { get; set; }
|
||||
[ObservableProperty][NotifyPropertyChangedRecipients] public partial AutoScanInterval ScanInterval { get; set; } = AutoScanInterval.Disabled;
|
||||
|
||||
public Uri GetStandardUri()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsLocal)
|
||||
{
|
||||
return new Uri(UriPath);
|
||||
}
|
||||
|
||||
var builder = new UriBuilder
|
||||
{
|
||||
Scheme = UriScheme ?? "file",
|
||||
Host = UriHost,
|
||||
Port = UriPort,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(UriPath))
|
||||
{
|
||||
string cleanPath = UriPath.Replace("\\", "/");
|
||||
if (!cleanPath.StartsWith("/")) cleanPath = "/" + cleanPath;
|
||||
builder.Path = cleanPath;
|
||||
}
|
||||
|
||||
return builder.Uri;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new Uri("about:blank");
|
||||
}
|
||||
}
|
||||
|
||||
// 例:smb://user@host:445/share/path
|
||||
[JsonIgnore]
|
||||
public string UriString => GetStandardUri().AbsoluteUri;
|
||||
|
||||
[JsonIgnore]
|
||||
public string ConnectionSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsLocal) return Path;
|
||||
return $"{SourceType} - {Path} {(string.IsNullOrEmpty(UserName) ? "" : $"({UserName})")}";
|
||||
if (IsLocal) return UriPath;
|
||||
return $"{UriScheme}://{UriHost}{(UriPort > 0 ? ":" + UriPort : "")}/{UriPath?.TrimStart('/', '\\')} {(string.IsNullOrEmpty(UserName) ? "" : $"({UserName})")}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +101,8 @@ namespace BetterLyrics.WinUI3.Models
|
||||
|
||||
public MediaFolder(string path)
|
||||
{
|
||||
Path = path;
|
||||
UriPath = path;
|
||||
SourceType = FileSourceType.Local;
|
||||
}
|
||||
|
||||
public IUnifiedFileSystem? CreateFileSystem()
|
||||
@@ -62,12 +115,13 @@ namespace BetterLyrics.WinUI3.Models
|
||||
|
||||
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),
|
||||
FileSourceType.Local => new LocalFileSystem(this),
|
||||
FileSourceType.SMB => new SMBFileSystem(this),
|
||||
FileSourceType.FTP => new FTPFileSystem(this),
|
||||
FileSourceType.WebDAV => new WebDavFileSystem(this),
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,11 @@ namespace BetterLyrics.WinUI3.Models
|
||||
[ObservableProperty][NotifyPropertyChangedRecipients] public partial LyricsSearchType LyricsSearchType { get; set; } = LyricsSearchType.Sequential;
|
||||
[ObservableProperty][NotifyPropertyChangedRecipients] public partial int MatchingThreshold { get; set; } = 40;
|
||||
|
||||
[JsonIgnore] public string LogoPath => PlayerIDHelper.GetLogoPath(Provider);
|
||||
[JsonIgnore] public string LogoPath => PlayerIdHelper.GetLogoPath(Provider);
|
||||
|
||||
[JsonIgnore] public string? DisplayName => PlayerIDHelper.GetDisplayName(Provider);
|
||||
[JsonIgnore] public string? DisplayName => PlayerIdHelper.GetDisplayName(Provider);
|
||||
|
||||
[JsonIgnore] public bool IsLXMusic => PlayerIDHelper.IsLXMusic(Provider);
|
||||
[JsonIgnore] public bool IsLXMusic => PlayerIdHelper.IsLXMusic(Provider);
|
||||
|
||||
public MediaSourceProviderInfo()
|
||||
{
|
||||
@@ -53,7 +53,7 @@ namespace BetterLyrics.WinUI3.Models
|
||||
IsEnabled = isEnable;
|
||||
switch (provider)
|
||||
{
|
||||
case Constants.PlayerID.AppleMusic:
|
||||
case Constants.PlayerId.AppleMusic:
|
||||
// Apple Music 的特性
|
||||
TimelineSyncThreshold = 1000;
|
||||
PositionOffset = 1000;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models
|
||||
{
|
||||
[Index(nameof(Title))]
|
||||
[Index(nameof(Artist))]
|
||||
[Index(nameof(StartedAt))] // 用于按时间排序查询(如:最近播放)
|
||||
[Index(nameof(PlayerId))]
|
||||
public class PlayHistoryItem
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)] // AutoIncrement
|
||||
public int Id { get; set; }
|
||||
|
||||
// 注意:作为索引列,必须加 MaxLength。
|
||||
// 如果不加,默认为 nvarchar(max),SQL Server 无法对其建立高效索引。
|
||||
[MaxLength(450)]
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
[MaxLength(450)]
|
||||
public string Artist { get; set; } = "";
|
||||
|
||||
// Album 没有索引,可以不限制长度,或者为了规范也限制一下
|
||||
public string Album { get; set; } = "";
|
||||
|
||||
public DateTime StartedAt { get; set; }
|
||||
|
||||
public double DurationPlayedMs { get; set; }
|
||||
|
||||
public double TotalDurationMs { get; set; }
|
||||
|
||||
// PlayerId 通常是个 GUID 或者短字符串,给 100 长度通常足够了,节省索引空间
|
||||
[MaxLength(100)]
|
||||
public string PlayerId { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models
|
||||
{
|
||||
public class PlayerStatDisplayItem
|
||||
{
|
||||
public string PlayerId { get; set; }
|
||||
public int PlayCount { get; set; }
|
||||
|
||||
public string PlayerName => PlayerIdHelper.GetDisplayName(PlayerId);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models.Settings
|
||||
|
||||
@@ -6,7 +6,7 @@ using System;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models
|
||||
{
|
||||
public partial class SongInfo : ObservableObject, ICloneable
|
||||
public partial class SongInfo : ObservableRecipient, ICloneable
|
||||
{
|
||||
[ObservableProperty]
|
||||
public partial string Album { get; set; }
|
||||
@@ -26,6 +26,8 @@ namespace BetterLyrics.WinUI3.Models
|
||||
[ObservableProperty]
|
||||
public partial string? SongId { get; set; } = null;
|
||||
|
||||
[ObservableProperty] public partial long StartedAt { get; set; } = DateTime.Now.ToBinary();
|
||||
|
||||
public string? LinkedFileName { get; set; } = null;
|
||||
|
||||
public double Duration => DurationMs / 1000;
|
||||
@@ -45,6 +47,7 @@ namespace BetterLyrics.WinUI3.Models
|
||||
PlayerId = this.PlayerId,
|
||||
SongId = this.SongId,
|
||||
LinkedFileName = this.LinkedFileName,
|
||||
StartedAt = this.StartedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,37 +6,18 @@ namespace BetterLyrics.WinUI3.Models
|
||||
{
|
||||
public partial class SongsTabInfo : BaseViewModel
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string Icon { get; set; }
|
||||
public string Icon { get; set; } = "";
|
||||
|
||||
public bool IsClosable { get; set; }
|
||||
public CommonSongProperty FilterProperty { get; set; } = CommonSongProperty.Title;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsStarred { get; set; }
|
||||
public string FilterValue { get; set; } = "";
|
||||
|
||||
public CommonSongProperty FilterProperty { get; set; }
|
||||
|
||||
public string FilterValue { get; set; }
|
||||
public bool IsDefault => Icon == "\uE8A9";
|
||||
|
||||
public SongsTabInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
Icon = string.Empty;
|
||||
IsClosable = true;
|
||||
IsStarred = false;
|
||||
FilterProperty = CommonSongProperty.Title;
|
||||
FilterValue = string.Empty;
|
||||
}
|
||||
|
||||
public SongsTabInfo(string name, string icon, bool isClosable, bool isStarred, CommonSongProperty filterProperty, string filterValue)
|
||||
{
|
||||
Name = name;
|
||||
Icon = icon;
|
||||
IsClosable = isClosable;
|
||||
IsStarred = isStarred;
|
||||
FilterProperty = filterProperty;
|
||||
FilterValue = filterValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models.Stats
|
||||
{
|
||||
public class ArtistPlayCount
|
||||
{
|
||||
public string Artist { get; set; }
|
||||
public int PlayCount { get; set; }
|
||||
public double TotalDurationSeconds { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models.Stats
|
||||
{
|
||||
public class HourlyStatBar
|
||||
{
|
||||
public int Hour { get; set; }
|
||||
public double NormalizedHeight { get; set; } // 0 - 100,用于UI高度
|
||||
public int RawCount { get; set; } // 实际播放数
|
||||
public string Label { get; set; } // Tooltip: "09:00 - 15 plays"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models.Stats
|
||||
{
|
||||
public class PlayerStats
|
||||
{
|
||||
public string PlayerId { get; set; }
|
||||
public int Count { get; set; }
|
||||
|
||||
public double DisplayWidth => (TotalCount > 0) ? (Count / (double)TotalCount) * 150 : 0;
|
||||
|
||||
public int TotalCount { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models.Stats
|
||||
{
|
||||
public class SongPlayCount
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string Artist { get; set; }
|
||||
public int PlayCount { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace BetterLyrics.WinUI3.Models
|
||||
{
|
||||
public class TrimmedTrack
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string Artist { get; set; }
|
||||
public string Album { get; set; }
|
||||
public int? Year { get; set; }
|
||||
public string Genre { get; set; }
|
||||
public string FilePath { get; set; }
|
||||
public int Duration { get; set; }
|
||||
public byte[]? AlbumArt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ namespace BetterLyrics.WinUI3.Renderer
|
||||
var blur = line.BlurAmountTransition.Value;
|
||||
var bounds = line.PhoneticCanvasTextLayout.LayoutBounds;
|
||||
|
||||
if (double.IsNaN(opacity)) return;
|
||||
|
||||
var destRect = new Rect(
|
||||
bounds.X + line.PhoneticPosition.X,
|
||||
bounds.Y + line.PhoneticPosition.Y,
|
||||
@@ -71,6 +73,8 @@ namespace BetterLyrics.WinUI3.Renderer
|
||||
var blur = line.BlurAmountTransition.Value;
|
||||
var bounds = line.TranslatedCanvasTextLayout.LayoutBounds;
|
||||
|
||||
if (double.IsNaN(opacity)) return;
|
||||
|
||||
var destRect = new Rect(
|
||||
bounds.X + line.TranslatedPosition.X,
|
||||
bounds.Y + line.TranslatedPosition.Y,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Services.FileSystemService;
|
||||
using BetterLyrics.WinUI3.Services.SettingsService;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
@@ -22,11 +23,13 @@ namespace BetterLyrics.WinUI3.Services.AlbumArtSearchService
|
||||
private readonly HttpClient _iTunesHttpClinet;
|
||||
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFileSystemService _fileSystemService;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public AlbumArtSearchService(ISettingsService settingsService, ILogger<AlbumArtSearchService> logger)
|
||||
public AlbumArtSearchService(ISettingsService settingsService, IFileSystemService fileSystemService, ILogger<AlbumArtSearchService> logger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_fileSystemService = fileSystemService;
|
||||
_logger = logger;
|
||||
_iTunesHttpClinet = new();
|
||||
}
|
||||
@@ -77,70 +80,54 @@ namespace BetterLyrics.WinUI3.Services.AlbumArtSearchService
|
||||
|
||||
private async Task<byte[]?> SearchFile(SongInfo songInfo)
|
||||
{
|
||||
foreach (var folder in _settingsService.AppSettings.LocalMediaFolders)
|
||||
var enabledIds = _settingsService.AppSettings.LocalMediaFolders
|
||||
.Where(f => f.IsEnabled)
|
||||
.Select(f => f.Id)
|
||||
.ToList();
|
||||
|
||||
if (enabledIds.Count == 0) return null;
|
||||
|
||||
var allFiles = await _fileSystemService.GetParsedFilesAsync(enabledIds);
|
||||
allFiles = allFiles.Where(x => FileHelper.MusicExtensions.Contains(Path.GetExtension(x.FileName))).ToList();
|
||||
|
||||
FilesIndexItem? bestMatch = null;
|
||||
|
||||
foreach (var item in allFiles)
|
||||
{
|
||||
if (!folder.IsEnabled) continue;
|
||||
var ext = Path.GetExtension(item.FileName).ToLower();
|
||||
if (!FileHelper.MusicExtensions.Contains(ext)) continue;
|
||||
|
||||
try
|
||||
bool isMetadataMatch = (item.Title == songInfo.Title && item.Artists == songInfo.DisplayArtists);
|
||||
|
||||
bool isFilenameMatch = StringHelper.IsSwitchableNormalizedMatch(
|
||||
Path.GetFileNameWithoutExtension(item.FileName),
|
||||
songInfo.DisplayArtists,
|
||||
songInfo.Title
|
||||
);
|
||||
|
||||
if (isMetadataMatch || isFilenameMatch)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var currentPath = foldersToScan.Dequeue();
|
||||
var items = await fs.GetFilesAsync(currentPath);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
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))
|
||||
{
|
||||
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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bestMatch = item;
|
||||
break;
|
||||
}
|
||||
catch
|
||||
}
|
||||
|
||||
if (bestMatch == null || string.IsNullOrEmpty(bestMatch.LocalAlbumArtPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(bestMatch.LocalAlbumArtPath))
|
||||
{
|
||||
return await File.ReadAllBytesAsync(bestMatch.LocalAlbumArtPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"读取本地缓存失败: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Models.Db;
|
||||
using BetterLyrics.WinUI3.Services.FileSystemService.Providers;
|
||||
using BetterLyrics.WinUI3.Services.LocalizationService;
|
||||
using BetterLyrics.WinUI3.Services.SettingsService;
|
||||
using BetterLyrics.WinUI3.ViewModels;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.FileSystemService
|
||||
{
|
||||
public partial class FileSystemService : BaseViewModel, IFileSystemService,
|
||||
IRecipient<PropertyChangedMessage<AutoScanInterval>>,
|
||||
IRecipient<PropertyChangedMessage<bool>>
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly ILogger<FileSystemService> _logger;
|
||||
|
||||
private readonly IDbContextFactory<FilesIndexDbContext> _contextFactory;
|
||||
|
||||
// 定时器字典
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _folderTimerTokens = new();
|
||||
// 当前正在执行的扫描任务字典
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _activeScanTokens = new();
|
||||
|
||||
private static readonly SemaphoreSlim _folderScanLock = new(1, 1);
|
||||
|
||||
public FileSystemService(
|
||||
ISettingsService settingsService,
|
||||
ILocalizationService localizationService,
|
||||
ILogger<FileSystemService> logger,
|
||||
IDbContextFactory<FilesIndexDbContext> contextFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_localizationService = localizationService;
|
||||
_settingsService = settingsService;
|
||||
_contextFactory = contextFactory;
|
||||
}
|
||||
|
||||
public async Task<List<FilesIndexItem>> GetFilesAsync(IUnifiedFileSystem provider, FilesIndexItem? parentFolder, string configId, bool forceRefresh = false)
|
||||
{
|
||||
string queryParentUri = parentFolder == null ? "" : parentFolder.Uri;
|
||||
if (parentFolder == null && !forceRefresh) forceRefresh = true;
|
||||
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
var cachedEntities = await context.FilesIndex
|
||||
.AsNoTracking() // 读操作不追踪,提升性能
|
||||
.Where(x => x.MediaFolderId == configId && x.ParentUri == queryParentUri)
|
||||
.ToListAsync();
|
||||
|
||||
bool needSync = forceRefresh || cachedEntities.Count == 0;
|
||||
|
||||
if (needSync)
|
||||
{
|
||||
// SyncAsync 内部自己管理 Context
|
||||
cachedEntities = await SyncAsync(provider, parentFolder, configId);
|
||||
}
|
||||
|
||||
return cachedEntities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从远端/本地同步文件至数据库
|
||||
/// </summary>
|
||||
private async Task<List<FilesIndexItem>> SyncAsync(IUnifiedFileSystem provider, FilesIndexItem? parentFolder, string configId)
|
||||
{
|
||||
List<FilesIndexItem> remoteItems;
|
||||
try
|
||||
{
|
||||
remoteItems = await provider.GetFilesAsync(parentFolder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Network sync error: {ex.Message}");
|
||||
return [];
|
||||
}
|
||||
|
||||
if (remoteItems == null) return [];
|
||||
|
||||
string targetParentUri = "";
|
||||
if (remoteItems.Count > 0)
|
||||
targetParentUri = remoteItems[0].ParentUri ?? "";
|
||||
else if (parentFolder != null)
|
||||
targetParentUri = parentFolder.Uri;
|
||||
else
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 开启事务 (EF Core 也能管理事务)
|
||||
using var transaction = await context.Database.BeginTransactionAsync();
|
||||
|
||||
// 1. 获取数据库中现有的该目录下的文件
|
||||
var dbItems = await context.FilesIndex
|
||||
.Where(x => x.MediaFolderId == configId && x.ParentUri == targetParentUri)
|
||||
.ToListAsync();
|
||||
|
||||
var dbMap = dbItems.ToDictionary(x => x.Uri, x => x);
|
||||
|
||||
// 2. 远端数据去重(防止 Provider 返回重复 Uri)
|
||||
var remoteDistinct = remoteItems
|
||||
.GroupBy(x => x.Uri)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
var remoteUris = new HashSet<string>();
|
||||
|
||||
// 3. 处理 新增 和 更新
|
||||
foreach (var remote in remoteDistinct)
|
||||
{
|
||||
remoteUris.Add(remote.Uri);
|
||||
|
||||
if (dbMap.TryGetValue(remote.Uri, out var existing))
|
||||
{
|
||||
// 检查是否变更
|
||||
bool isChanged = existing.FileSize != remote.FileSize ||
|
||||
existing.LastModified != remote.LastModified;
|
||||
|
||||
if (isChanged)
|
||||
{
|
||||
existing.FileSize = remote.FileSize;
|
||||
existing.LastModified = remote.LastModified;
|
||||
existing.IsMetadataParsed = false; // 标记重新解析
|
||||
|
||||
// EF Core 自动追踪 existing 的变化,无需手动 Update
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 新增
|
||||
// 注意:如果 Id 是自增的,不要手动赋值 Id,除非是 Guid
|
||||
context.FilesIndex.Add(remote);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 处理 删除 (数据库有,远端没有)
|
||||
foreach (var dbItem in dbItems)
|
||||
{
|
||||
if (!remoteUris.Contains(dbItem.Uri))
|
||||
{
|
||||
context.FilesIndex.Remove(dbItem);
|
||||
}
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
// 5. 返回最新数据
|
||||
// 这里的 dbItems 已经被 Update 更新了内存状态,但 Remove 的还在列表里,Add 的不在列表里
|
||||
// 所以最稳妥的是重新查一次,或者手动维护列表。为了准确性,重新查询 (AsNoTracking)
|
||||
var finalItems = await context.FilesIndex
|
||||
.AsNoTracking()
|
||||
.Where(x => x.MediaFolderId == configId && x.ParentUri == targetParentUri)
|
||||
.ToListAsync();
|
||||
|
||||
FolderUpdated?.Invoke(this, targetParentUri);
|
||||
|
||||
return finalItems;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Database sync error: {ex.Message}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateMetadataAsync(FilesIndexItem entity)
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 使用 EF Core 7.0+ 的 ExecuteUpdateAsync 高效更新
|
||||
// 这会直接生成 UPDATE SQL,不经过内存加载,性能极高
|
||||
await context.FilesIndex
|
||||
.Where(x => x.Id == entity.Id) // 优先用 Id
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(p => p.Title, entity.Title)
|
||||
.SetProperty(p => p.Artists, entity.Artists)
|
||||
.SetProperty(p => p.Album, entity.Album)
|
||||
.SetProperty(p => p.Year, entity.Year)
|
||||
.SetProperty(p => p.Bitrate, entity.Bitrate)
|
||||
.SetProperty(p => p.SampleRate, entity.SampleRate)
|
||||
.SetProperty(p => p.BitDepth, entity.BitDepth)
|
||||
.SetProperty(p => p.Duration, entity.Duration)
|
||||
.SetProperty(p => p.AudioFormatName, entity.AudioFormatName)
|
||||
.SetProperty(p => p.AudioFormatShortName, entity.AudioFormatShortName)
|
||||
.SetProperty(p => p.Encoder, entity.Encoder)
|
||||
.SetProperty(p => p.EmbeddedLyrics, entity.EmbeddedLyrics)
|
||||
.SetProperty(p => p.LocalAlbumArtPath, entity.LocalAlbumArtPath)
|
||||
.SetProperty(p => p.IsMetadataParsed, true)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Stream?> OpenFileAsync(IUnifiedFileSystem provider, FilesIndexItem entity)
|
||||
{
|
||||
return await provider.OpenReadAsync(entity);
|
||||
}
|
||||
|
||||
public async Task DeleteCacheForMediaFolderAsync(MediaFolder folder)
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
folder.IndexingProgress = 0;
|
||||
folder.StatusSeverity = InfoBarSeverity.Informational;
|
||||
folder.StatusText = _localizationService.GetLocalizedString("FileSystemServicePrepareToClean");
|
||||
folder.IsProcessing = true;
|
||||
});
|
||||
|
||||
if (_folderTimerTokens.TryRemove(folder.Id, out var timerCts))
|
||||
{
|
||||
timerCts.Cancel();
|
||||
timerCts.Dispose();
|
||||
_logger.LogInformation("DeleteCacheForMediaFolderAsync: {}", "cts.Dispose();");
|
||||
}
|
||||
|
||||
if (_activeScanTokens.TryGetValue(folder.Id, out var activeScanCts))
|
||||
{
|
||||
activeScanCts.Cancel();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _folderScanLock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
folder.StatusText = _localizationService.GetLocalizedString("FileSystemServiceCleaningCache");
|
||||
});
|
||||
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
await context.FilesIndex
|
||||
.Where(x => x.MediaFolderId == folder.Id)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// VACUUM 是 SQLite 特有的命令
|
||||
if (context.Database.IsSqlite())
|
||||
{
|
||||
await context.Database.ExecuteSqlRawAsync("VACUUM");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_folderScanLock.Release();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("DeleteCacheForMediaFolderAsync: {}", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
folder.IsProcessing = false;
|
||||
folder.LastSyncTime = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ScanMediaFolderAsync(MediaFolder folder, CancellationToken token = default)
|
||||
{
|
||||
if (folder == null || !folder.IsEnabled) return;
|
||||
|
||||
using var scanCts = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
_activeScanTokens[folder.Id] = scanCts;
|
||||
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
folder.StatusSeverity = InfoBarSeverity.Informational;
|
||||
folder.IsProcessing = true;
|
||||
folder.IndexingProgress = 0;
|
||||
folder.StatusText = _localizationService.GetLocalizedString("FileSystemServiceWaitingForScan");
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await _folderScanLock.WaitAsync(scanCts.Token);
|
||||
|
||||
_dispatcherQueue.TryEnqueue(() => folder.StatusText = _localizationService.GetLocalizedString("FileSystemServiceConnecting"));
|
||||
|
||||
using var fs = folder.CreateFileSystem();
|
||||
if (fs == null || !await fs.ConnectAsync())
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
folder.StatusSeverity = InfoBarSeverity.Error;
|
||||
folder.StatusText = _localizationService.GetLocalizedString("FileSystemServiceConnectFailed");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_dispatcherQueue.TryEnqueue(() => folder.StatusText = _localizationService.GetLocalizedString("FileSystemServiceFetchingFileList"));
|
||||
|
||||
var filesToProcess = new List<FilesIndexItem>();
|
||||
var foldersToScan = new Queue<FilesIndexItem?>();
|
||||
foldersToScan.Enqueue(null); // 根目录
|
||||
|
||||
while (foldersToScan.Count > 0)
|
||||
{
|
||||
if (scanCts.Token.IsCancellationRequested) return;
|
||||
|
||||
var currentParent = foldersToScan.Dequeue();
|
||||
var items = await GetFilesAsync(fs, currentParent, folder.Id, forceRefresh: true);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.IsDirectory)
|
||||
{
|
||||
foldersToScan.Enqueue(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
var ext = Path.GetExtension(item.FileName).ToLower();
|
||||
if (FileHelper.AllSupportedExtensions.Contains(ext))
|
||||
{
|
||||
filesToProcess.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int total = filesToProcess.Count;
|
||||
int current = 0;
|
||||
|
||||
foreach (var item in filesToProcess)
|
||||
{
|
||||
if (scanCts.Token.IsCancellationRequested) return;
|
||||
|
||||
current++;
|
||||
|
||||
if (current % 10 == 0 || current == total)
|
||||
{
|
||||
double progress = (double)current / total * 100;
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
folder.IndexingProgress = progress;
|
||||
folder.StatusText = $"{_localizationService.GetLocalizedString("FileSystemServiceParsing")} {current}/{total}";
|
||||
});
|
||||
}
|
||||
|
||||
if (item.IsMetadataParsed) continue;
|
||||
|
||||
var ext = Path.GetExtension(item.FileName).ToLower();
|
||||
|
||||
try
|
||||
{
|
||||
if (FileHelper.MusicExtensions.Contains(ext))
|
||||
{
|
||||
using var originalStream = await OpenFileAsync(fs, item);
|
||||
if (originalStream == null) continue;
|
||||
|
||||
ExtendedTrack track;
|
||||
if (originalStream.CanSeek)
|
||||
{
|
||||
track = new ExtendedTrack(item, originalStream);
|
||||
}
|
||||
else
|
||||
{
|
||||
using var memStream = new MemoryStream();
|
||||
await originalStream.CopyToAsync(memStream, scanCts.Token);
|
||||
memStream.Position = 0;
|
||||
track = new ExtendedTrack(item, memStream);
|
||||
}
|
||||
|
||||
if (track.Duration > 0)
|
||||
{
|
||||
string? artPath = await SaveAlbumArtToDiskAsync(track);
|
||||
|
||||
item.Title = track.Title;
|
||||
item.Artists = track.Artist;
|
||||
item.Album = track.Album;
|
||||
item.Year = track.Year;
|
||||
item.Bitrate = track.Bitrate;
|
||||
item.SampleRate = track.SampleRate;
|
||||
item.BitDepth = track.BitDepth;
|
||||
item.Duration = track.Duration;
|
||||
item.AudioFormatName = track.AudioFormatName;
|
||||
item.AudioFormatShortName = track.AudioFormatShortName;
|
||||
item.Encoder = track.Encoder;
|
||||
item.EmbeddedLyrics = track.RawLyrics;
|
||||
item.LocalAlbumArtPath = artPath;
|
||||
item.IsMetadataParsed = true;
|
||||
}
|
||||
}
|
||||
else if (FileHelper.LyricExtensions.Contains(ext))
|
||||
{
|
||||
using var stream = await OpenFileAsync(fs, item);
|
||||
if (stream != null)
|
||||
{
|
||||
using var reader = new StreamReader(stream);
|
||||
string content = await reader.ReadToEndAsync();
|
||||
item.EmbeddedLyrics = content;
|
||||
item.IsMetadataParsed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.IsMetadataParsed)
|
||||
{
|
||||
// 更新操作:直接调用 UpdateMetadataAsync
|
||||
// 此时不需要 _dbLock,因为 UpdateMetadataAsync 内部会 CreateDbContextAsync
|
||||
// 而 _folderScanLock 已经保证了当前文件夹扫描的独占性
|
||||
await UpdateMetadataAsync(item);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("ScanMediaFolderAsync: {}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
folder.StatusSeverity = InfoBarSeverity.Success;
|
||||
folder.StatusText = _localizationService.GetLocalizedString("FileSystemServiceReady");
|
||||
folder.LastSyncTime = DateTime.Now;
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常取消
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
folder.StatusText = ex.Message;
|
||||
folder.StatusSeverity = InfoBarSeverity.Error;
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_folderScanLock.Release();
|
||||
_activeScanTokens.TryRemove(folder.Id, out _);
|
||||
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
folder.IsProcessing = false;
|
||||
folder.IndexingProgress = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<FilesIndexItem>> GetParsedFilesAsync()
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// SQL: SELECT * FROM FileCache WHERE IsMetadataParsed = 1 AND MediaFolderId IN (...)
|
||||
return await context.FilesIndex
|
||||
.AsNoTracking()
|
||||
.Where(x => x.IsMetadataParsed)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<FilesIndexItem>> GetParsedFilesAsync(IEnumerable<string> enabledConfigIds)
|
||||
{
|
||||
if (enabledConfigIds == null || !enabledConfigIds.Any())
|
||||
{
|
||||
return new List<FilesIndexItem>();
|
||||
}
|
||||
|
||||
var idList = enabledConfigIds.ToList();
|
||||
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// SQL: SELECT * FROM FileCache WHERE IsMetadataParsed = 1 AND MediaFolderId IN (...)
|
||||
return await context.FilesIndex
|
||||
.AsNoTracking()
|
||||
.Where(x => x.IsMetadataParsed && idList.Contains(x.MediaFolderId))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public void StartAllFolderTimers()
|
||||
{
|
||||
foreach (var folder in _settingsService.AppSettings.LocalMediaFolders)
|
||||
{
|
||||
if (folder.IsEnabled)
|
||||
{
|
||||
UpdateFolderTimer(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFolderTimer(MediaFolder folder)
|
||||
{
|
||||
if (_folderTimerTokens.TryRemove(folder.Id, out var oldCts))
|
||||
{
|
||||
oldCts.Cancel();
|
||||
oldCts.Dispose();
|
||||
}
|
||||
|
||||
if (!folder.IsEnabled || folder.ScanInterval == AutoScanInterval.Disabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var newCts = new CancellationTokenSource();
|
||||
_folderTimerTokens[folder.Id] = newCts;
|
||||
|
||||
TimeSpan period = folder.ScanInterval switch
|
||||
{
|
||||
AutoScanInterval.Every15Minutes => TimeSpan.FromMinutes(15),
|
||||
AutoScanInterval.EveryHour => TimeSpan.FromHours(1),
|
||||
AutoScanInterval.Every6Hours => TimeSpan.FromHours(6),
|
||||
AutoScanInterval.Daily => TimeSpan.FromDays(1),
|
||||
_ => TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(period);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(newCts.Token))
|
||||
{
|
||||
await ScanMediaFolderAsync(folder, newCts.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"文件夹 {folder.Name} 定时扫描出错: {ex.Message}");
|
||||
}
|
||||
}, newCts.Token);
|
||||
}
|
||||
|
||||
public event EventHandler<string>? FolderUpdated;
|
||||
|
||||
private async Task<string?> SaveAlbumArtToDiskAsync(ExtendedTrack track)
|
||||
{
|
||||
// 代码未变,纯 IO 操作
|
||||
var picData = track.AlbumArtByteArray;
|
||||
if (picData == null || picData.Length == 0) return null;
|
||||
|
||||
try
|
||||
{
|
||||
string hash = ComputeHashForBytes(picData);
|
||||
string safeName = hash + ".jpg";
|
||||
|
||||
string localPath = Path.Combine(PathHelper.LocalAlbumArtCacheDirectory, safeName);
|
||||
|
||||
if (File.Exists(localPath)) return localPath;
|
||||
|
||||
await File.WriteAllBytesAsync(localPath, picData);
|
||||
|
||||
return localPath;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string ComputeHashForBytes(byte[] data)
|
||||
{
|
||||
using (var md5 = System.Security.Cryptography.MD5.Create())
|
||||
{
|
||||
var hashBytes = md5.ComputeHash(data);
|
||||
return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public void Receive(PropertyChangedMessage<AutoScanInterval> message)
|
||||
{
|
||||
if (message.Sender is MediaFolder mediaFolder)
|
||||
{
|
||||
if (message.PropertyName == nameof(MediaFolder.ScanInterval))
|
||||
{
|
||||
UpdateFolderTimer(mediaFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Receive(PropertyChangedMessage<bool> message)
|
||||
{
|
||||
if (message.Sender is MediaFolder mediaFolder)
|
||||
{
|
||||
if (message.PropertyName == nameof(MediaFolder.IsEnabled))
|
||||
{
|
||||
UpdateFolderTimer(mediaFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Services.FileSystemService.Providers;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.FileSystemService
|
||||
{
|
||||
public interface IFileSystemService
|
||||
{
|
||||
/// <summary>
|
||||
/// 从数据库拉取文件(必要时需要从远端/本地同步至数据库)
|
||||
/// </summary>
|
||||
/// <param name="provider"></param>
|
||||
/// <param name="parentFolder"></param>
|
||||
/// <param name="configId"></param>
|
||||
/// <param name="forceRefresh">强制需要从远端/本地同步至数据库</param>
|
||||
/// <returns></returns>
|
||||
Task<List<FilesIndexItem>> GetFilesAsync(IUnifiedFileSystem provider, FilesIndexItem? parentFolder, string configId, bool forceRefresh = false);
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件(通过远端/本地流)
|
||||
/// </summary>
|
||||
/// <param name="provider"></param>
|
||||
/// <param name="entity"></param>
|
||||
/// <returns></returns>
|
||||
Task<Stream?> OpenFileAsync(IUnifiedFileSystem provider, FilesIndexItem entity);
|
||||
|
||||
/// <summary>
|
||||
/// 更新数据库(单个文件)
|
||||
/// </summary>
|
||||
/// <param name="entity"></param>
|
||||
/// <returns></returns>
|
||||
Task UpdateMetadataAsync(FilesIndexItem entity);
|
||||
|
||||
/// <summary>
|
||||
/// 从数据库删除
|
||||
/// </summary>
|
||||
/// <param name="folder"></param>
|
||||
/// <returns></returns>
|
||||
Task DeleteCacheForMediaFolderAsync(MediaFolder folder);
|
||||
|
||||
/// <summary>
|
||||
/// 从数据库拉取文件(必要时需要从远端/本地同步至数据库)。对于需要解析的文件,打开流填充元数据并回写至数据库。
|
||||
/// </summary>
|
||||
/// <param name="folder"></param>
|
||||
/// <returns></returns>
|
||||
Task ScanMediaFolderAsync(MediaFolder folder, CancellationToken token = default);
|
||||
|
||||
/// <summary>
|
||||
/// 从数据库拉取全部已解析的数据
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
Task<List<FilesIndexItem>> GetParsedFilesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 从数据库拉取全部已解析的且其所属的 MediaFolder 在应用内处于开启状态的数据
|
||||
/// </summary>
|
||||
/// <param name="enabledConfigIds"></param>
|
||||
/// <returns></returns>
|
||||
Task<List<FilesIndexItem>> GetParsedFilesAsync(IEnumerable<string> enabledConfigIds);
|
||||
|
||||
void StartAllFolderTimers();
|
||||
|
||||
event EventHandler<string> FolderUpdated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.FileSystemService
|
||||
{
|
||||
public interface IUnifiedFileSystem : IDisposable
|
||||
{
|
||||
Task<bool> ConnectAsync();
|
||||
/// <summary>
|
||||
/// 从流拉取
|
||||
/// </summary>
|
||||
/// <param name="parentFolder"></param>
|
||||
/// <returns></returns>
|
||||
Task<List<FilesIndexItem>> GetFilesAsync(FilesIndexItem? parentFolder = null);
|
||||
/// <summary>
|
||||
/// 打开流
|
||||
/// </summary>
|
||||
/// <param name="file"></param>
|
||||
/// <returns></returns>
|
||||
Task<Stream?> OpenReadAsync(FilesIndexItem file);
|
||||
Task DisconnectAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using FluentFTP;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net; // 用于 WebUtility.UrlDecode
|
||||
using System.Text; // ★ 修复 Encoding 报错的关键
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.FileSystemService.Providers
|
||||
{
|
||||
public partial class FTPFileSystem : IUnifiedFileSystem
|
||||
{
|
||||
private readonly AsyncFtpClient _client;
|
||||
private readonly MediaFolder _config;
|
||||
|
||||
public FTPFileSystem(MediaFolder config)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
|
||||
var ftpConfig = new FtpConfig
|
||||
{
|
||||
ConnectTimeout = 5000,
|
||||
DataConnectionConnectTimeout = 5000,
|
||||
ReadTimeout = 10000,
|
||||
|
||||
// 忽略证书错误
|
||||
ValidateAnyCertificate = true
|
||||
};
|
||||
|
||||
int port = _config.UriPort > 0 ? _config.UriPort : 0;
|
||||
|
||||
_client = new AsyncFtpClient(
|
||||
_config.UriHost,
|
||||
_config.UserName ?? "anonymous",
|
||||
_config.Password ?? "",
|
||||
port,
|
||||
ftpConfig
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<bool> ConnectAsync()
|
||||
{
|
||||
if (_client.IsConnected) return true;
|
||||
await _client.AutoConnect(); // AutoConnect 会自动尝试 FTP/FTPS
|
||||
return _client.IsConnected;
|
||||
}
|
||||
|
||||
public async Task<List<FilesIndexItem>> GetFilesAsync(FilesIndexItem? parentFolder = null)
|
||||
{
|
||||
var result = new List<FilesIndexItem>();
|
||||
|
||||
string targetServerPath;
|
||||
Uri parentUri;
|
||||
|
||||
if (parentFolder == null)
|
||||
{
|
||||
var rootUri = _config.GetStandardUri();
|
||||
targetServerPath = rootUri.AbsolutePath;
|
||||
parentUri = rootUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetServerPath = GetServerPathFromUri(parentFolder.Uri);
|
||||
parentUri = new Uri(parentFolder.Uri);
|
||||
}
|
||||
|
||||
targetServerPath = WebUtility.UrlDecode(targetServerPath).Replace("\\", "/");
|
||||
if (string.IsNullOrEmpty(targetServerPath)) targetServerPath = "/";
|
||||
|
||||
try
|
||||
{
|
||||
var items = await _client.GetListing(targetServerPath, FtpListOption.Auto);
|
||||
|
||||
string baseUriSchema = $"{parentUri.Scheme}://{parentUri.Host}";
|
||||
if (parentUri.Port > 0) baseUriSchema += $":{parentUri.Port}";
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
// 跳过 . 和 ..
|
||||
if (item.Name == "." || item.Name == "..") continue;
|
||||
|
||||
// 只处理文件和文件夹
|
||||
if (item.Type != FtpObjectType.File && item.Type != FtpObjectType.Directory) continue;
|
||||
|
||||
// 只处理特定后缀文件
|
||||
if (item.Type == FtpObjectType.File)
|
||||
{
|
||||
string extension = Path.GetExtension(item.Name);
|
||||
if (string.IsNullOrEmpty(extension) || !FileHelper.AllSupportedExtensions.Contains(extension)) continue;
|
||||
}
|
||||
|
||||
var builder = new UriBuilder(baseUriSchema)
|
||||
{
|
||||
Path = item.FullName
|
||||
};
|
||||
|
||||
result.Add(new FilesIndexItem
|
||||
{
|
||||
MediaFolderId = _config.Id,
|
||||
// 如果是根目录扫描,ParentUri 用 Config 的;否则用传入文件夹的
|
||||
ParentUri = parentFolder?.Uri ?? _config.GetStandardUri().AbsoluteUri,
|
||||
|
||||
Uri = builder.Uri.AbsoluteUri, // 标准化 URI
|
||||
|
||||
FileName = item.Name,
|
||||
IsDirectory = item.Type == FtpObjectType.Directory,
|
||||
FileSize = item.Size,
|
||||
// 防止某些服务器返回 MinValue
|
||||
LastModified = item.Modified == DateTime.MinValue ? DateTime.Now : item.Modified,
|
||||
|
||||
IsMetadataParsed = false
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"FTP列表获取失败: {targetServerPath} - {ex.Message}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Stream?> OpenReadAsync(FilesIndexItem file)
|
||||
{
|
||||
if (file == null) return null;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 还原服务器路径
|
||||
string serverPath = GetServerPathFromUri(file.Uri);
|
||||
|
||||
// 2. 解码 (Uri 里的空格是 %20,FTP 需要真实空格)
|
||||
serverPath = WebUtility.UrlDecode(serverPath);
|
||||
|
||||
// 3. 返回流
|
||||
// 注意:FluentFTP 的 OpenRead 依赖于连接保持活跃
|
||||
return await _client.OpenRead(serverPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"打开文件流失败: {file.FileName} - {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
if (_client.IsConnected)
|
||||
{
|
||||
await _client.Disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
// 私有辅助方法
|
||||
private string GetServerPathFromUri(string uriString)
|
||||
{
|
||||
var uri = new Uri(uriString);
|
||||
return uri.AbsolutePath; // 这里拿到的比如是 "/Music/Song%201.mp3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.FileSystemService.Providers
|
||||
{
|
||||
public partial class LocalFileSystem : IUnifiedFileSystem
|
||||
{
|
||||
private readonly MediaFolder _config;
|
||||
private readonly string _rootLocalPath;
|
||||
|
||||
public LocalFileSystem(MediaFolder config)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
_rootLocalPath = config.UriPath;
|
||||
}
|
||||
|
||||
public Task<bool> ConnectAsync()
|
||||
{
|
||||
var isExisted = Directory.Exists(_rootLocalPath);
|
||||
if (isExisted)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new FileNotFoundException(null, _rootLocalPath);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<FilesIndexItem>> GetFilesAsync(FilesIndexItem? parentFolder = null)
|
||||
{
|
||||
var result = new List<FilesIndexItem>();
|
||||
|
||||
string targetPath;
|
||||
string parentUriString;
|
||||
|
||||
try
|
||||
{
|
||||
if (parentFolder == null)
|
||||
{
|
||||
targetPath = _rootLocalPath;
|
||||
parentUriString = _config.GetStandardUri().AbsoluteUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
var uri = new Uri(parentFolder.Uri);
|
||||
targetPath = uri.LocalPath;
|
||||
parentUriString = parentFolder.Uri;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(targetPath)) return result;
|
||||
|
||||
var dirInfo = new DirectoryInfo(targetPath);
|
||||
|
||||
foreach (var item in dirInfo.EnumerateFileSystemInfos())
|
||||
{
|
||||
// 跳过系统/隐藏文件
|
||||
if ((item.Attributes & FileAttributes.Hidden) != 0 || (item.Attributes & FileAttributes.System) != 0) continue;
|
||||
|
||||
bool isDir = (item.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
|
||||
|
||||
if (!isDir)
|
||||
{
|
||||
// 过滤后缀名
|
||||
if (string.IsNullOrEmpty(item.Extension) || !FileHelper.AllSupportedExtensions.Contains(item.Extension)) continue;
|
||||
}
|
||||
|
||||
var itemUri = new Uri(item.FullName).AbsoluteUri;
|
||||
|
||||
long size = 0;
|
||||
|
||||
if (!isDir && item is FileInfo fi)
|
||||
{
|
||||
size = fi.Length;
|
||||
}
|
||||
|
||||
result.Add(new FilesIndexItem
|
||||
{
|
||||
MediaFolderId = _config.Id, // 关联配置 ID
|
||||
|
||||
ParentUri = parentUriString, // 记录父级 URI
|
||||
|
||||
Uri = itemUri,
|
||||
|
||||
FileName = item.Name,
|
||||
IsDirectory = isDir,
|
||||
|
||||
FileSize = size,
|
||||
LastModified = item.LastWriteTime
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Local scan error: {ex.Message}");
|
||||
}
|
||||
|
||||
return await Task.FromResult(result);
|
||||
}
|
||||
|
||||
public async Task<Stream?> OpenReadAsync(FilesIndexItem entity)
|
||||
{
|
||||
if (entity == null) return null;
|
||||
|
||||
string localPath = new Uri(entity.Uri).LocalPath;
|
||||
|
||||
// 使用 FileShare.Read 允许其他程序同时读取
|
||||
// 使用 useAsync: true 优化异步读写性能
|
||||
return new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync() => await Task.CompletedTask;
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using SMBLibrary;
|
||||
using SMBLibrary.Client;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.FileSystemService.Providers
|
||||
{
|
||||
public partial class SMBFileSystem : IUnifiedFileSystem
|
||||
{
|
||||
private SMB2Client? _client;
|
||||
private ISMBFileStore? _fileStore;
|
||||
|
||||
// 保存配置对象的引用
|
||||
private readonly MediaFolder _config;
|
||||
|
||||
// 缓存解析出来的 Share 名称,因为 TreeConnect 要用
|
||||
private string _shareName;
|
||||
|
||||
public SMBFileSystem(MediaFolder config)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
|
||||
// 在构造时就解析好 Share 名称,避免后续重复解析
|
||||
var uri = _config.GetStandardUri();
|
||||
|
||||
// Segments[0] 是 "/", Segments[1] 是 "ShareName/"
|
||||
if (uri.Segments.Length > 1)
|
||||
{
|
||||
_shareName = uri.Segments[1].TrimEnd('/');
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果没有 ShareName,这在 SMB 中通常是不合法的,但在根目录下可能发生
|
||||
_shareName = "";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ConnectAsync()
|
||||
{
|
||||
_client = new SMB2Client();
|
||||
|
||||
// 连接主机
|
||||
bool connected = _client.Connect(_config.UriHost, SMBTransportType.DirectTCPTransport);
|
||||
if (!connected) return false;
|
||||
|
||||
// 登录
|
||||
var status = _client.Login(string.Empty, _config.UserName, _config.Password);
|
||||
if (status != NTStatus.STATUS_SUCCESS) return false;
|
||||
|
||||
// 连接共享目录 (TreeConnect)
|
||||
// SMBLibrary 必须先连接到 Share,后续所有文件操作都是基于这个 Share 的相对路径
|
||||
if (string.IsNullOrEmpty(_shareName)) return false;
|
||||
|
||||
_fileStore = _client.TreeConnect(_shareName, out status);
|
||||
return status == NTStatus.STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取文件列表
|
||||
/// </summary>
|
||||
/// <param name="parentFolder">
|
||||
/// 传入要列出的文件夹实体。
|
||||
/// 如果传入 null,则默认列出 MediaFolder 配置的根目录。
|
||||
/// </param>
|
||||
public async Task<List<FilesIndexItem>> GetFilesAsync(FilesIndexItem? parentFolder = null)
|
||||
{
|
||||
var result = new List<FilesIndexItem>();
|
||||
if (_fileStore == null) return result;
|
||||
|
||||
string smbPath = GetPathRelativeToShare(parentFolder);
|
||||
|
||||
var statusRet = _fileStore.CreateFile(out object handle, out FileStatus status, smbPath,
|
||||
AccessMask.GENERIC_READ, SMBLibrary.FileAttributes.Directory, ShareAccess.Read,
|
||||
CreateDisposition.FILE_OPEN, CreateOptions.FILE_DIRECTORY_FILE, null);
|
||||
|
||||
if (statusRet != NTStatus.STATUS_SUCCESS) return result;
|
||||
|
||||
string parentUriString = parentFolder?.Uri ?? _config.GetStandardUri().AbsoluteUri;
|
||||
|
||||
List<QueryDirectoryFileInformation> fileInfo;
|
||||
|
||||
do
|
||||
{
|
||||
statusRet = _fileStore.QueryDirectory(out fileInfo, handle, "*", FileInformationClass.FileDirectoryInformation);
|
||||
|
||||
// 如果查询失败或者没有更多文件,fileInfo 可能是 null,直接跳出
|
||||
if (statusRet != NTStatus.STATUS_SUCCESS && statusRet != NTStatus.STATUS_NO_MORE_FILES)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// 如果是 NO_MORE_FILES 但 fileInfo 依然有残留数据(极少见),或者是 SUCCESS
|
||||
if (fileInfo != null)
|
||||
{
|
||||
foreach (var item in fileInfo.Cast<FileDirectoryInformation>())
|
||||
{
|
||||
if (item.FileName == "." || item.FileName == "..") continue;
|
||||
|
||||
// 过滤隐藏文件和系统文件
|
||||
if ((item.FileAttributes & SMBLibrary.FileAttributes.Hidden) == SMBLibrary.FileAttributes.Hidden ||
|
||||
(item.FileAttributes & SMBLibrary.FileAttributes.System) == SMBLibrary.FileAttributes.System)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool isDir = (item.FileAttributes & SMBLibrary.FileAttributes.Directory) == SMBLibrary.FileAttributes.Directory;
|
||||
|
||||
// 后缀名过滤
|
||||
if (!isDir)
|
||||
{
|
||||
string extension = Path.GetExtension(item.FileName);
|
||||
if (string.IsNullOrEmpty(extension) || !FileHelper.AllSupportedExtensions.Contains(extension)) continue;
|
||||
}
|
||||
|
||||
if (!parentUriString.EndsWith("/")) parentUriString += "/";
|
||||
var baseUri = new Uri(parentUriString);
|
||||
var newUri = new Uri(baseUri, item.FileName);
|
||||
|
||||
result.Add(new FilesIndexItem
|
||||
{
|
||||
MediaFolderId = _config.Id,
|
||||
ParentUri = parentFolder?.Uri ?? _config.GetStandardUri().AbsoluteUri,
|
||||
|
||||
Uri = newUri.AbsoluteUri,
|
||||
|
||||
FileName = item.FileName,
|
||||
IsDirectory = (item.FileAttributes & SMBLibrary.FileAttributes.Directory) == SMBLibrary.FileAttributes.Directory,
|
||||
FileSize = item.AllocationSize,
|
||||
LastModified = item.ChangeTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (statusRet == NTStatus.STATUS_NO_MORE_FILES) break;
|
||||
|
||||
} while (statusRet == NTStatus.STATUS_SUCCESS);
|
||||
|
||||
_fileStore.CloseFile(handle);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开文件流
|
||||
/// </summary>
|
||||
/// <param name="file">只需要传入文件实体即可</param>
|
||||
public async Task<Stream?> OpenReadAsync(FilesIndexItem file)
|
||||
{
|
||||
if (_fileStore == null || file == null) return null;
|
||||
|
||||
string smbPath = GetPathRelativeToShare(file);
|
||||
|
||||
var ret = _fileStore.CreateFile(out object handle, out FileStatus status, smbPath,
|
||||
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();
|
||||
}
|
||||
|
||||
private string GetPathRelativeToShare(FilesIndexItem? entity)
|
||||
{
|
||||
Uri targetUri;
|
||||
|
||||
if (entity == null)
|
||||
{
|
||||
targetUri = _config.GetStandardUri();
|
||||
}
|
||||
else
|
||||
{
|
||||
targetUri = new Uri(entity.Uri);
|
||||
}
|
||||
|
||||
string absolutePath = Uri.UnescapeDataString(targetUri.AbsolutePath);
|
||||
string cleanPath = absolutePath.TrimStart('/');
|
||||
int slashIndex = cleanPath.IndexOf('/');
|
||||
|
||||
if (slashIndex == -1)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
string relativePath = cleanPath.Substring(slashIndex + 1);
|
||||
|
||||
return relativePath.Replace("/", "\\");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,17 @@ using SMBLibrary.Client;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Models.FileSystem
|
||||
namespace BetterLyrics.WinUI3.Services.FileSystemService.Providers
|
||||
{
|
||||
public partial class SMBReadOnlyStream : Stream
|
||||
{
|
||||
private readonly ISMBFileStore _store;
|
||||
private readonly object _handle;
|
||||
private long _position;
|
||||
private long _length; // 新增:缓存文件长度
|
||||
private long _length;
|
||||
|
||||
// SMB 协议建议的最大读取块大小 (64KB 是最安全的通用值)
|
||||
private const int MaxReadChunkSize = 65536;
|
||||
|
||||
public SMBReadOnlyStream(ISMBFileStore store, object handle)
|
||||
{
|
||||
@@ -25,18 +28,15 @@ namespace BetterLyrics.WinUI3.Models.FileSystem
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果获取失败,这是一个严重问题,意味着无法 Seek 到末尾
|
||||
// 暂时设为 0,但后续读取可能会出问题
|
||||
_length = 0;
|
||||
_length = 0; // 这是一个风险点,但为了不 crash 先设为 0
|
||||
System.Diagnostics.Debug.WriteLine($"SMB GetLength Error: {status}");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -45,30 +45,49 @@ namespace BetterLyrics.WinUI3.Models.FileSystem
|
||||
|
||||
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);
|
||||
int totalBytesRead = 0;
|
||||
int remainingRequest = count;
|
||||
|
||||
// 为了安全,保留对 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)
|
||||
// 循环读取,直到读完请求的数量,或者文件结束
|
||||
while (remainingRequest > 0)
|
||||
{
|
||||
throw new IOException($"SMB Read failed. Status: {status} (Pos: {_position}, Req: {bytesToRequest})");
|
||||
// 计算剩余文件长度
|
||||
long remainingFile = _length - _position;
|
||||
if (remainingFile <= 0) break; // 已到末尾
|
||||
|
||||
// 计算本次 SMB 请求的大小 (取三者最小值:请求剩余量、文件剩余量、SMB最大块限制)
|
||||
int bytesToReadThisChunk = (int)Math.Min(Math.Min(remainingRequest, remainingFile), MaxReadChunkSize);
|
||||
|
||||
// 发送 SMB 请求
|
||||
var status = _store.ReadFile(out byte[] data, _handle, _position, bytesToReadThisChunk);
|
||||
|
||||
// 处理结果
|
||||
if (status == NTStatus.STATUS_END_OF_FILE) break;
|
||||
|
||||
if (status != NTStatus.STATUS_SUCCESS)
|
||||
{
|
||||
// 遇到错误抛出详细信息
|
||||
throw new IOException($"SMB Read failed. Status: {status}, Position: {_position}, ChunkReq: {bytesToReadThisChunk}");
|
||||
}
|
||||
|
||||
if (data == null || data.Length == 0) break;
|
||||
|
||||
// 复制数据到输出 buffer
|
||||
Array.Copy(data, 0, buffer, offset + totalBytesRead, data.Length);
|
||||
|
||||
// 更新指针和计数器
|
||||
_position += data.Length;
|
||||
totalBytesRead += data.Length;
|
||||
remainingRequest -= data.Length;
|
||||
|
||||
// 如果实际读到的比请求的少,通常意味着提前到了 EOF,或者网络包较小
|
||||
// 这里选择继续循环尝试,直到读不够或者明确 EOF
|
||||
if (data.Length < bytesToReadThisChunk) break;
|
||||
}
|
||||
|
||||
if (data == null || data.Length == 0) return 0;
|
||||
|
||||
Array.Copy(data, 0, buffer, offset, data.Length);
|
||||
_position += data.Length;
|
||||
return data.Length;
|
||||
return totalBytesRead;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
@@ -88,10 +107,9 @@ namespace BetterLyrics.WinUI3.Models.FileSystem
|
||||
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.");
|
||||
throw new IOException("Seek before beginning.");
|
||||
}
|
||||
|
||||
_position = newPos;
|
||||
@@ -111,4 +129,4 @@ namespace BetterLyrics.WinUI3.Models.FileSystem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using WebDav;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.FileSystemService.Providers
|
||||
{
|
||||
public partial class WebDavFileSystem : IUnifiedFileSystem
|
||||
{
|
||||
private readonly WebDavClient _client;
|
||||
private readonly MediaFolder _config;
|
||||
private readonly Uri _baseAddress;
|
||||
|
||||
public WebDavFileSystem(MediaFolder config)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
|
||||
// 构建 BaseAddress (只包含 http://host:port/)
|
||||
// MediaFolder.GetStandardUri() 返回的是带路径的完整 URI (http://host:port/path)
|
||||
// 提取出根用于初始化 WebDavClient
|
||||
var fullUri = _config.GetStandardUri();
|
||||
|
||||
// 提取 "http://host:port"
|
||||
_baseAddress = new Uri($"{fullUri.Scheme}://{fullUri.Authority}");
|
||||
|
||||
_client = new WebDavClient(new WebDavClientParams
|
||||
{
|
||||
BaseAddress = _baseAddress,
|
||||
Credentials = new System.Net.NetworkCredential(_config.UserName, _config.Password)
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<bool> ConnectAsync()
|
||||
{
|
||||
var result = await _client.Propfind(_config.GetStandardUri().AbsoluteUri);
|
||||
return result.IsSuccessful;
|
||||
}
|
||||
|
||||
public async Task<List<FilesIndexItem>> GetFilesAsync(FilesIndexItem? parentFolder = null)
|
||||
{
|
||||
var list = new List<FilesIndexItem>();
|
||||
|
||||
Uri targetUri;
|
||||
if (parentFolder == null)
|
||||
{
|
||||
targetUri = _config.GetStandardUri();
|
||||
}
|
||||
else
|
||||
{
|
||||
targetUri = new Uri(parentFolder.Uri);
|
||||
}
|
||||
|
||||
var result = await _client.Propfind(targetUri.AbsoluteUri);
|
||||
|
||||
if (result.IsSuccessful)
|
||||
{
|
||||
string parentUriString = targetUri.AbsoluteUri;
|
||||
if (!parentUriString.EndsWith("/")) parentUriString += "/";
|
||||
|
||||
string targetPathClean = targetUri.AbsolutePath.TrimEnd('/');
|
||||
|
||||
foreach (var res in result.Resources)
|
||||
{
|
||||
var itemUri = new Uri(_baseAddress, res.Uri);
|
||||
|
||||
// 过滤掉文件夹自身
|
||||
if (itemUri.AbsolutePath.TrimEnd('/') == targetPathClean) continue;
|
||||
|
||||
string? name = res.DisplayName;
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
name = itemUri.AbsolutePath.TrimEnd('/').Split('/').Last();
|
||||
name = System.Net.WebUtility.UrlDecode(name);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
|
||||
if (name.StartsWith(".")) continue;
|
||||
|
||||
bool isDir = res.IsCollection;
|
||||
if (!isDir)
|
||||
{
|
||||
string extension = System.IO.Path.GetExtension(name);
|
||||
// 如果后缀为空或不在白名单,跳过
|
||||
if (string.IsNullOrEmpty(extension) || !FileHelper.AllSupportedExtensions.Contains(extension)) continue;
|
||||
}
|
||||
|
||||
list.Add(new FilesIndexItem
|
||||
{
|
||||
MediaFolderId = _config.Id,
|
||||
|
||||
ParentUri = parentFolder?.Uri ?? _config.GetStandardUri().AbsoluteUri,
|
||||
|
||||
Uri = itemUri.AbsoluteUri,
|
||||
|
||||
FileName = name,
|
||||
IsDirectory = res.IsCollection,
|
||||
|
||||
FileSize = res.ContentLength ?? 0,
|
||||
LastModified = res.LastModifiedDate ?? DateTime.MinValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<Stream?> OpenReadAsync(FilesIndexItem entity)
|
||||
{
|
||||
if (entity == null) return null;
|
||||
|
||||
// WebDAV 获取流,直接使用完整 URI
|
||||
var res = await _client.GetRawFile(entity.Uri);
|
||||
|
||||
if (!res.IsSuccessful)
|
||||
throw new IOException($"WebDAV Error {res.StatusCode}: {res.Description}");
|
||||
|
||||
return res.Stream;
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync() => await Task.CompletedTask;
|
||||
|
||||
public void Dispose() => _client?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -16,9 +17,9 @@ using Windows.Graphics.Imaging;
|
||||
using Windows.Storage.Streams;
|
||||
using Windows.UI;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
namespace BetterLyrics.WinUI3.Services.GSMTCService
|
||||
{
|
||||
public partial class MediaSessionsService : IMediaSessionsService
|
||||
public partial class GSMTCService : IGSMTCService
|
||||
{
|
||||
private readonly LatestOnlyTaskRunner _albumArtRefreshRunner = new();
|
||||
|
||||
@@ -41,15 +42,13 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
{
|
||||
_logger.LogInformation("RefreshArtAlbum");
|
||||
|
||||
if (CurrentSongInfo == null)
|
||||
IBuffer? buffer = null;
|
||||
if (CurrentSongInfo != SongInfoExtensions.Placeholder)
|
||||
{
|
||||
_logger.LogWarning("CurrentSongInfo == null");
|
||||
return;
|
||||
buffer = await Task.Run(async () => await _albumArtSearchService.SearchAsync(CurrentSongInfo, _SMTCAlbumArtBuffer, token), token);
|
||||
if (token.IsCancellationRequested) return;
|
||||
}
|
||||
|
||||
IBuffer? buffer = await Task.Run(async () => await _albumArtSearchService.SearchAsync(CurrentSongInfo, _SMTCAlbumArtBuffer, token), token);
|
||||
if (token.IsCancellationRequested) return;
|
||||
|
||||
if (buffer == null)
|
||||
{
|
||||
using var placeHolderStream = await ImageHelper.GetAlbumArtPlaceholderAsync();
|
||||
@@ -3,13 +3,15 @@ using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Parsers.LyricsParser;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.WinUI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
namespace BetterLyrics.WinUI3.Services.GSMTCService
|
||||
{
|
||||
public partial class MediaSessionsService : IMediaSessionsService
|
||||
public partial class GSMTCService : IGSMTCService
|
||||
{
|
||||
private LatestOnlyTaskRunner _refreshLyricsRunner = new();
|
||||
|
||||
@@ -24,15 +26,10 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
CurrentLyricsSearchResult = null;
|
||||
CurrentLyricsData = LyricsData.GetLoadingPlaceholder();
|
||||
|
||||
if (CurrentSongInfo != null)
|
||||
if (CurrentSongInfo != SongInfoExtensions.Placeholder)
|
||||
{
|
||||
CurrentLyricsSearchResult = await Task.Run(async () => await _lyrcsSearchService.SearchSmartlyAsync(
|
||||
CurrentSongInfo,
|
||||
true,
|
||||
CurrentMediaSourceProviderInfo?.LyricsSearchType,
|
||||
token),
|
||||
token);
|
||||
if (token.IsCancellationRequested) return;
|
||||
CurrentSongInfo, true, CurrentMediaSourceProviderInfo?.LyricsSearchType, token), token);
|
||||
|
||||
if (CurrentLyricsSearchResult != null)
|
||||
{
|
||||
@@ -40,14 +37,14 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
|
||||
(CurrentLyricsData, CurrentLyricsSearchResult.TransliterationProvider, CurrentLyricsSearchResult.TranslationProvider) =
|
||||
await Task.Run(async () => await lyricsParser.Parse(
|
||||
_translationService,
|
||||
_transliterationService,
|
||||
_settingsService.AppSettings.TranslationSettings,
|
||||
CurrentLyricsSearchResult,
|
||||
token),
|
||||
token);
|
||||
_translationService, _transliterationService, _settingsService.AppSettings.TranslationSettings, CurrentLyricsSearchResult, token), token);
|
||||
}
|
||||
}
|
||||
|
||||
if (CurrentLyricsSearchResult == null)
|
||||
{
|
||||
CurrentLyricsData = LyricsData.GetNotfoundPlaceholder();
|
||||
}
|
||||
}
|
||||
|
||||
public async void UpdateLyrics()
|
||||
@@ -11,8 +11,9 @@ using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Models.Settings;
|
||||
using BetterLyrics.WinUI3.Services.AlbumArtSearchService;
|
||||
using BetterLyrics.WinUI3.Services.DiscordService;
|
||||
using BetterLyrics.WinUI3.Services.LibWatcherService;
|
||||
using BetterLyrics.WinUI3.Services.LastFMService;
|
||||
using BetterLyrics.WinUI3.Services.LyricsSearchService;
|
||||
using BetterLyrics.WinUI3.Services.PlayHistoryService;
|
||||
using BetterLyrics.WinUI3.Services.SettingsService;
|
||||
using BetterLyrics.WinUI3.Services.TranslationService;
|
||||
using BetterLyrics.WinUI3.Services.TransliterationService;
|
||||
@@ -25,8 +26,10 @@ using CommunityToolkit.WinUI;
|
||||
using EvtSource;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices.WindowsRuntime;
|
||||
using System.Text.Json;
|
||||
@@ -36,71 +39,118 @@ using Windows.Media.Control;
|
||||
using Windows.Storage.Streams;
|
||||
using WindowsMediaController;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
namespace BetterLyrics.WinUI3.Services.GSMTCService
|
||||
{
|
||||
public partial class MediaSessionsService : BaseViewModel, IMediaSessionsService,
|
||||
public partial class GSMTCService : BaseViewModel, IGSMTCService,
|
||||
IRecipient<PropertyChangedMessage<bool>>,
|
||||
IRecipient<PropertyChangedMessage<string>>,
|
||||
IRecipient<PropertyChangedMessage<ChineseRomanization>>
|
||||
IRecipient<PropertyChangedMessage<ChineseRomanization>>,
|
||||
IRecipient<PropertyChangedMessage<DateTime?>>
|
||||
{
|
||||
private EventSourceReader? _sse = null;
|
||||
private readonly MediaManager _mediaManager = new();
|
||||
private IBuffer? _SMTCAlbumArtBuffer = null;
|
||||
|
||||
private MediaManager.MediaSession? _currentDesiredSession = null;
|
||||
|
||||
private readonly IAlbumArtSearchService _albumArtSearchService;
|
||||
private readonly ILyricsSearchService _lyrcsSearchService;
|
||||
private readonly ITranslationService _translationService;
|
||||
private readonly ITransliterationService _transliterationService;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly ILibWatcherService _libWatcherService;
|
||||
private readonly IDiscordService _discordService;
|
||||
private readonly ILogger<MediaSessionsService> _logger;
|
||||
private readonly IPlayHistoryService _playHistoryService;
|
||||
private readonly ILastFMService _lastFMService;
|
||||
private readonly ILogger<GSMTCService> _logger;
|
||||
|
||||
private double _lxMusicPositionSeconds = 0;
|
||||
private byte[]? _lxMusicAlbumArtBytes = null;
|
||||
|
||||
private readonly DispatcherQueueTimer? _onMediaPropsChangedTimer;
|
||||
private readonly DispatcherTimer _scrobbleTimer;
|
||||
|
||||
[ObservableProperty][NotifyPropertyChangedRecipients] public partial bool IsScrobbled { get; set; } = false;
|
||||
[ObservableProperty] public partial TimeSpan ScrobbledDuration { get; set; } = TimeSpan.Zero;
|
||||
[ObservableProperty] public partial TimeSpan TargetScrobbledDuration { get; set; } = TimeSpan.Zero;
|
||||
|
||||
[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; } = SongInfoExtensions.Placeholder;
|
||||
[ObservableProperty][NotifyPropertyChangedRecipients] public partial SongInfo CurrentSongInfo { get; private set; } = SongInfoExtensions.Placeholder;
|
||||
|
||||
[ObservableProperty] public partial MediaSourceProviderInfo? CurrentMediaSourceProviderInfo { get; set; }
|
||||
|
||||
public MediaSessionsService(
|
||||
public GSMTCService(
|
||||
ISettingsService settingsService,
|
||||
IAlbumArtSearchService albumArtSearchService,
|
||||
ILyricsSearchService lyricsSearchService,
|
||||
ILibWatcherService libWatcherService,
|
||||
IDiscordService discordService,
|
||||
ITranslationService libreTranslateService,
|
||||
ITransliterationService transliterationService,
|
||||
ILogger<MediaSessionsService> logger)
|
||||
IPlayHistoryService playHistoryService,
|
||||
ILastFMService lastFMService,
|
||||
ILogger<GSMTCService> logger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_albumArtSearchService = albumArtSearchService;
|
||||
_lyrcsSearchService = lyricsSearchService;
|
||||
_libWatcherService = libWatcherService;
|
||||
_translationService = libreTranslateService;
|
||||
_transliterationService = transliterationService;
|
||||
_discordService = discordService;
|
||||
_playHistoryService = playHistoryService;
|
||||
_lastFMService = lastFMService;
|
||||
_logger = logger;
|
||||
|
||||
_scrobbleTimer = new();
|
||||
_scrobbleTimer.Interval = TimeSpan.FromSeconds(1);
|
||||
_scrobbleTimer.Tick += ScrobbleTimer_Tick;
|
||||
|
||||
_onMediaPropsChangedTimer = _dispatcherQueue.CreateTimer();
|
||||
|
||||
_settingsService.AppSettings.MediaSourceProvidersInfo.ItemPropertyChanged += MediaSourceProvidersInfo_ItemPropertyChanged;
|
||||
|
||||
_settingsService.AppSettings.LocalMediaFolders.CollectionChanged += LocalMediaFolders_CollectionChanged;
|
||||
_settingsService.AppSettings.LocalMediaFolders.ItemPropertyChanged += LocalMediaFolders_ItemPropertyChanged;
|
||||
|
||||
_settingsService.AppSettings.MappedSongSearchQueries.CollectionChanged += MappedSongSearchQueries_CollectionChanged;
|
||||
_settingsService.AppSettings.MappedSongSearchQueries.ItemPropertyChanged += MappedSongSearchQueries_ItemPropertyChanged;
|
||||
|
||||
_libWatcherService.MusicLibraryFilesChanged += LibWatcherService_MusicLibraryFilesChanged;
|
||||
|
||||
InitMediaManager();
|
||||
}
|
||||
|
||||
private void ScrobbleTimer_Tick(object? sender, object e)
|
||||
{
|
||||
if (!IsScrobbled)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CurrentSongInfo.Title) && CurrentSongInfo.Title != "N/A")
|
||||
{
|
||||
ScrobbledDuration += _scrobbleTimer.Interval;
|
||||
if (ScrobbledDuration >= TargetScrobbledDuration)
|
||||
{
|
||||
// 写入本地播放记录
|
||||
var playHistoryItem = CurrentSongInfo.ToPlayHistoryItem(ScrobbledDuration.TotalMilliseconds);
|
||||
if (playHistoryItem != null)
|
||||
{
|
||||
// 后台
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await _playHistoryService.AddLogAsync(playHistoryItem);
|
||||
});
|
||||
_logger.LogInformation("ScrobbleTimer_Tick: {} scrobbled", CurrentSongInfo.Title);
|
||||
}
|
||||
// 写入 Last.fm 播放记录
|
||||
var isLastFMEnabled = CurrentMediaSourceProviderInfo?.IsLastFMTrackEnabled ?? false;
|
||||
if (isLastFMEnabled)
|
||||
{
|
||||
// 后台
|
||||
_ = Task.Run(() => _lastFMService.TrackAsync(CurrentSongInfo));
|
||||
}
|
||||
|
||||
IsScrobbled = true;
|
||||
ScrobbledDuration = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void MappedSongSearchQueries_ItemPropertyChanged(object? sender, ItemPropertyChangedEventArgs e)
|
||||
{
|
||||
UpdateLyrics();
|
||||
@@ -111,12 +161,6 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
UpdateLyrics();
|
||||
}
|
||||
|
||||
private void LocalMediaFolders_ItemPropertyChanged(object? sender, ItemPropertyChangedEventArgs e)
|
||||
{
|
||||
UpdateAlbumArt();
|
||||
UpdateLyrics();
|
||||
}
|
||||
|
||||
private void LocalMediaFolders_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
UpdateAlbumArt();
|
||||
@@ -144,16 +188,9 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
}
|
||||
}
|
||||
|
||||
private void LibWatcherService_MusicLibraryFilesChanged(object? sender, LibChangedEventArgs e)
|
||||
private MediaSourceProviderInfo? GetCurrentDesiredMediaSourceProviderInfo()
|
||||
{
|
||||
UpdateAlbumArt();
|
||||
UpdateLyrics();
|
||||
}
|
||||
|
||||
private MediaSourceProviderInfo? GetCurrentMediaSourceProviderInfo()
|
||||
{
|
||||
var desiredSession = GetCurrentSession();
|
||||
return _settingsService.AppSettings.MediaSourceProvidersInfo.FirstOrDefault(x => x.Provider == desiredSession?.Id);
|
||||
return _settingsService.AppSettings.MediaSourceProvidersInfo.FirstOrDefault(x => x.Provider == _currentDesiredSession?.Id);
|
||||
}
|
||||
|
||||
private bool IsMediaSourceEnabled(string id)
|
||||
@@ -161,7 +198,7 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
var found = _settingsService.AppSettings.MediaSourceProvidersInfo.FirstOrDefault(s => s.Provider == id);
|
||||
if (_settingsService.AppSettings.MusicGallerySettings.LyricsWindowStatus.IsOpened)
|
||||
{
|
||||
if (PlayerIDHelper.IsBetterLyrics(found?.Provider))
|
||||
if (PlayerIdHelper.IsBetterLyrics(found?.Provider))
|
||||
{
|
||||
return found?.IsEnabled ?? true;
|
||||
}
|
||||
@@ -183,6 +220,10 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
|
||||
private void InitMediaManager()
|
||||
{
|
||||
_mediaManager.Start();
|
||||
|
||||
_mediaManager.CurrentMediaSessions.ToList().ForEach(x => RecordMediaSession(x.Value.Id));
|
||||
|
||||
_mediaManager.OnAnySessionOpened += MediaManager_OnAnySessionOpened;
|
||||
_mediaManager.OnAnySessionClosed += MediaManager_OnAnySessionClosed;
|
||||
_mediaManager.OnFocusedSessionChanged += MediaManager_OnFocusedSessionChanged;
|
||||
@@ -190,182 +231,157 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
_mediaManager.OnAnyPlaybackStateChanged += MediaManager_OnAnyPlaybackStateChanged;
|
||||
_mediaManager.OnAnyTimelinePropertyChanged += MediaManager_OnAnyTimelinePropertyChanged;
|
||||
|
||||
_mediaManager.Start();
|
||||
|
||||
MediaManager_OnFocusedSessionChanged(null);
|
||||
_mediaManager.CurrentMediaSessions.ToList().ForEach(x => RecordMediaSourceProviderInfo(x.Value));
|
||||
OnDesiredSessionChanged(true);
|
||||
}
|
||||
|
||||
private async void MediaManager_OnFocusedSessionChanged(MediaManager.MediaSession? mediaSession)
|
||||
private void OnDesiredSessionChanged(bool firstTime = false)
|
||||
{
|
||||
if (!_mediaManager.IsStarted) return;
|
||||
|
||||
await SendFocusedMessagesAsync();
|
||||
}
|
||||
|
||||
private void MediaManager_OnAnyTimelinePropertyChanged(MediaManager.MediaSession? mediaSession, GlobalSystemMediaTransportControlsSessionTimelineProperties? timelineProperties)
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
|
||||
var desiredSession = GetCurrentDesiredSession();
|
||||
if (firstTime || desiredSession != _currentDesiredSession)
|
||||
{
|
||||
if (!_mediaManager.IsStarted) return;
|
||||
if (mediaSession == null)
|
||||
_currentDesiredSession = desiredSession;
|
||||
if (_currentDesiredSession == null)
|
||||
{
|
||||
CurrentPosition = TimeSpan.Zero;
|
||||
return;
|
||||
}
|
||||
|
||||
var desiredSession = GetCurrentSession();
|
||||
|
||||
if (mediaSession != desiredSession) return;
|
||||
|
||||
if (!IsMediaSourceEnabled(mediaSession.Id))
|
||||
{
|
||||
CurrentPosition = TimeSpan.Zero;
|
||||
SendNullMessages();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (IsMediaSourceTimelineSyncEnabled(mediaSession.Id))
|
||||
{
|
||||
CurrentPosition = timelineProperties?.Position ?? TimeSpan.Zero;
|
||||
CurrentSongInfo?.DurationMs = timelineProperties?.EndTime.TotalMilliseconds ?? 0;
|
||||
}
|
||||
_ = SendFocusedMessagesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void MediaManager_OnFocusedSessionChanged(MediaManager.MediaSession? mediaSession)
|
||||
{
|
||||
OnDesiredSessionChanged();
|
||||
}
|
||||
|
||||
private void MediaManager_OnAnyTimelinePropertyChanged(MediaManager.MediaSession mediaSession, GlobalSystemMediaTransportControlsSessionTimelineProperties timelineProperties)
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
if (mediaSession != _currentDesiredSession) return;
|
||||
|
||||
CurrentPosition = timelineProperties.Position;
|
||||
CurrentSongInfo.DurationMs = timelineProperties.EndTime.TotalMilliseconds;
|
||||
UpdateTargetScrobbledDuration();
|
||||
if (CurrentPosition.TotalSeconds == 0)
|
||||
{
|
||||
IsScrobbled = false;
|
||||
ScrobbledDuration = TimeSpan.Zero;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void MediaManager_OnAnyPlaybackStateChanged(MediaManager.MediaSession? mediaSession, GlobalSystemMediaTransportControlsSessionPlaybackInfo? playbackInfo)
|
||||
private void MediaManager_OnAnyPlaybackStateChanged(MediaManager.MediaSession mediaSession, GlobalSystemMediaTransportControlsSessionPlaybackInfo playbackInfo)
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, (() =>
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
if (!_mediaManager.IsStarted) return;
|
||||
if (mediaSession == null)
|
||||
if (mediaSession != _currentDesiredSession) return;
|
||||
|
||||
CurrentIsPlaying = playbackInfo.PlaybackStatus switch
|
||||
{
|
||||
CurrentIsPlaying = false;
|
||||
return;
|
||||
}
|
||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
var desiredSession = GetCurrentSession();
|
||||
|
||||
//RecordMediaSourceProviderInfo(mediaSession);
|
||||
if (mediaSession != desiredSession) return;
|
||||
|
||||
if (!IsMediaSourceEnabled(mediaSession.Id))
|
||||
if (CurrentIsPlaying)
|
||||
{
|
||||
CurrentIsPlaying = false;
|
||||
_scrobbleTimer.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
CurrentIsPlaying = playbackInfo?.PlaybackStatus switch
|
||||
{
|
||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing => true,
|
||||
_ => false,
|
||||
};
|
||||
_scrobbleTimer.Stop();
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private void MediaManager_OnAnyMediaPropertyChanged(MediaManager.MediaSession? mediaSession, GlobalSystemMediaTransportControlsSessionMediaProperties? mediaProperties)
|
||||
private void MediaManager_OnAnyMediaPropertyChanged(MediaManager.MediaSession mediaSession, GlobalSystemMediaTransportControlsSessionMediaProperties mediaProperties)
|
||||
{
|
||||
_onMediaPropsChangedTimer?.Debounce(() =>
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, async () =>
|
||||
_dispatcherQueue.TryEnqueue(async () =>
|
||||
{
|
||||
if (!_mediaManager.IsStarted) return;
|
||||
if (mediaSession == null)
|
||||
if (mediaSession != _currentDesiredSession) return;
|
||||
|
||||
string sessionId = mediaSession.Id;
|
||||
|
||||
var currentMediaSourceProviderInfo = GetCurrentDesiredMediaSourceProviderInfo();
|
||||
if (currentMediaSourceProviderInfo?.ResetPositionOffsetOnSongChanged == true)
|
||||
{
|
||||
CurrentSongInfo = SongInfoExtensions.Placeholder;
|
||||
currentMediaSourceProviderInfo?.PositionOffset = 0;
|
||||
}
|
||||
|
||||
string? sessionId = mediaSession?.Id;
|
||||
string fixedTitle = mediaProperties.Title;
|
||||
string fixedArtist = mediaProperties.Artist;
|
||||
string fixedAlbum = mediaProperties.AlbumTitle;
|
||||
string? songId = null;
|
||||
|
||||
var desiredSession = GetCurrentSession();
|
||||
|
||||
if (mediaSession != desiredSession) return;
|
||||
|
||||
if (sessionId != null && !IsMediaSourceEnabled(sessionId))
|
||||
if (PlayerIdHelper.IsAppleMusic(sessionId))
|
||||
{
|
||||
CurrentSongInfo = SongInfoExtensions.Placeholder;
|
||||
fixedArtist = mediaProperties.Artist.Split(" — ").First();
|
||||
fixedAlbum = mediaProperties.Artist.Split(" — ").Last();
|
||||
fixedAlbum = fixedAlbum.Replace(" - Single", "");
|
||||
fixedAlbum = fixedAlbum.Replace(" - EP", "");
|
||||
}
|
||||
else if (PlayerIdHelper.IsNeteaseFamily(sessionId))
|
||||
{
|
||||
songId = mediaProperties.Genres
|
||||
.FirstOrDefault(x => x.StartsWith(ExtendedGenreFiled.NetEaseCloudMusicTrackID))?
|
||||
.Replace(ExtendedGenreFiled.NetEaseCloudMusicTrackID, "");
|
||||
}
|
||||
else if (sessionId == PlayerId.QQMusic)
|
||||
{
|
||||
songId = mediaProperties.Genres
|
||||
.FirstOrDefault(x => x.StartsWith(ExtendedGenreFiled.QQMusicTrackID))?
|
||||
.Replace(ExtendedGenreFiled.QQMusicTrackID, "");
|
||||
}
|
||||
|
||||
if (PlayerIDHelper.IsLXMusic(sessionId))
|
||||
{
|
||||
StopSSE();
|
||||
}
|
||||
var linkedFileName = mediaProperties.Genres
|
||||
.FirstOrDefault(x => x.StartsWith(ExtendedGenreFiled.FileName))?
|
||||
.Replace(ExtendedGenreFiled.FileName, "");
|
||||
|
||||
_SMTCAlbumArtBuffer = null;
|
||||
CurrentSongInfo = new()
|
||||
{
|
||||
Title = fixedTitle,
|
||||
Artists = fixedArtist.SplitByCommonSplitter(),
|
||||
Album = fixedAlbum,
|
||||
DurationMs = mediaSession.ControlSession.GetTimelineProperties().EndTime.TotalMilliseconds,
|
||||
PlayerId = sessionId,
|
||||
SongId = songId,
|
||||
LinkedFileName = linkedFileName,
|
||||
StartedAt = DateTime.Now.ToBinary(),
|
||||
};
|
||||
|
||||
UpdateTargetScrobbledDuration();
|
||||
IsScrobbled = false;
|
||||
ScrobbledDuration = TimeSpan.Zero;
|
||||
|
||||
if (PlayerIdHelper.IsLXMusic(sessionId))
|
||||
{
|
||||
StartSSE();
|
||||
}
|
||||
else
|
||||
{
|
||||
var currentMediaSourceProviderInfo = GetCurrentMediaSourceProviderInfo();
|
||||
if (currentMediaSourceProviderInfo?.ResetPositionOffsetOnSongChanged == true)
|
||||
{
|
||||
currentMediaSourceProviderInfo?.PositionOffset = 0;
|
||||
}
|
||||
StopSSE();
|
||||
}
|
||||
|
||||
string? fixedArtist = mediaProperties?.Artist;
|
||||
string? fixedAlbum = mediaProperties?.AlbumTitle;
|
||||
string? songId = null;
|
||||
|
||||
if (PlayerIDHelper.IsAppleMusic(sessionId))
|
||||
{
|
||||
fixedArtist = mediaProperties?.Artist.Split(" — ").FirstOrDefault();
|
||||
fixedAlbum = mediaProperties?.Artist.Split(" — ").LastOrDefault();
|
||||
fixedAlbum = fixedAlbum?.Replace(" - Single", "");
|
||||
fixedAlbum = fixedAlbum?.Replace(" - EP", "");
|
||||
}
|
||||
else if (PlayerIDHelper.IsNeteaseFamily(sessionId))
|
||||
{
|
||||
songId = mediaProperties?.Genres
|
||||
.FirstOrDefault(x => x.StartsWith(ExtendedGenreFiled.NetEaseCloudMusicTrackID))?
|
||||
.Replace(ExtendedGenreFiled.NetEaseCloudMusicTrackID, "");
|
||||
}
|
||||
else if (sessionId == PlayerID.QQMusic)
|
||||
{
|
||||
songId = mediaProperties?.Genres
|
||||
.FirstOrDefault(x => x.StartsWith(ExtendedGenreFiled.QQMusicTrackID))?
|
||||
.Replace(ExtendedGenreFiled.QQMusicTrackID, "");
|
||||
}
|
||||
|
||||
var linkedFileName = mediaProperties?.Genres
|
||||
.FirstOrDefault(x => x.StartsWith(ExtendedGenreFiled.FileName))?
|
||||
.Replace(ExtendedGenreFiled.FileName, "");
|
||||
|
||||
CurrentSongInfo = new SongInfo
|
||||
{
|
||||
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,
|
||||
LinkedFileName = linkedFileName
|
||||
};
|
||||
|
||||
if (PlayerIDHelper.IsLXMusic(sessionId))
|
||||
{
|
||||
StartSSE();
|
||||
}
|
||||
else
|
||||
{
|
||||
StopSSE();
|
||||
}
|
||||
|
||||
if (PlayerIDHelper.IsLXMusic(sessionId) && _lxMusicAlbumArtBytes != null)
|
||||
{
|
||||
_SMTCAlbumArtBuffer = _lxMusicAlbumArtBytes.AsBuffer();
|
||||
}
|
||||
else if (mediaProperties?.Thumbnail is IRandomAccessStreamReference streamReference)
|
||||
{
|
||||
_SMTCAlbumArtBuffer = await ImageHelper.ToBufferAsync(streamReference);
|
||||
}
|
||||
else
|
||||
{
|
||||
_SMTCAlbumArtBuffer = null;
|
||||
}
|
||||
if (PlayerIdHelper.IsLXMusic(sessionId) && _lxMusicAlbumArtBytes != null)
|
||||
{
|
||||
_SMTCAlbumArtBuffer = _lxMusicAlbumArtBytes.AsBuffer();
|
||||
}
|
||||
else if (mediaProperties.Thumbnail is IRandomAccessStreamReference streamReference)
|
||||
{
|
||||
_SMTCAlbumArtBuffer = await ImageHelper.ToBufferAsync(streamReference);
|
||||
}
|
||||
else
|
||||
{
|
||||
_SMTCAlbumArtBuffer = null;
|
||||
}
|
||||
|
||||
_logger.LogInformation("MediaManager_OnAnyMediaPropertyChanged {SongInfo}", CurrentSongInfo);
|
||||
|
||||
CurrentMediaSourceProviderInfo = GetCurrentMediaSourceProviderInfo();
|
||||
CurrentMediaSourceProviderInfo = GetCurrentDesiredMediaSourceProviderInfo();
|
||||
|
||||
UpdateAlbumArt();
|
||||
UpdateLyrics();
|
||||
@@ -378,80 +394,65 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
|
||||
private void MediaManager_OnAnySessionClosed(MediaManager.MediaSession mediaSession)
|
||||
{
|
||||
if (!_mediaManager.IsStarted) return;
|
||||
if (mediaSession == null) return;
|
||||
|
||||
if (_mediaManager.CurrentMediaSessions.Count == 0)
|
||||
{
|
||||
SendNullMessages();
|
||||
}
|
||||
OnDesiredSessionChanged();
|
||||
}
|
||||
|
||||
private void MediaManager_OnAnySessionOpened(MediaManager.MediaSession mediaSession)
|
||||
{
|
||||
if (!_mediaManager.IsStarted) return;
|
||||
if (mediaSession == null) return;
|
||||
|
||||
RecordMediaSourceProviderInfo(mediaSession);
|
||||
SendFocusedMessagesAsync().ConfigureAwait(false);
|
||||
var id = mediaSession.Id;
|
||||
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
RecordMediaSession(id);
|
||||
OnDesiredSessionChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private MediaManager.MediaSession? GetCurrentSession()
|
||||
private void RecordMediaSession(string id)
|
||||
{
|
||||
var found = _settingsService.AppSettings.MediaSourceProvidersInfo.FirstOrDefault(x => x.Provider == id);
|
||||
if (found == null)
|
||||
{
|
||||
_settingsService.AppSettings.MediaSourceProvidersInfo.Add(new MediaSourceProviderInfo(id, _settingsService.AppSettings.GeneralSettings.ListenOnNewPlaybackSource));
|
||||
}
|
||||
}
|
||||
|
||||
private MediaManager.MediaSession? GetCurrentDesiredSession()
|
||||
{
|
||||
var focusedSession = _mediaManager.GetFocusedSession();
|
||||
if (focusedSession == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (IsMediaSourceEnabled(focusedSession.Id))
|
||||
if (focusedSession != null && IsMediaSourceEnabled(focusedSession.Id))
|
||||
{
|
||||
return focusedSession;
|
||||
}
|
||||
else
|
||||
|
||||
foreach (var session in _mediaManager.CurrentMediaSessions.Values)
|
||||
{
|
||||
foreach (var session in _mediaManager.CurrentMediaSessions.Values)
|
||||
if (IsMediaSourceEnabled(session.Id))
|
||||
{
|
||||
if (IsMediaSourceEnabled(session.Id))
|
||||
{
|
||||
return session;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void RecordMediaSourceProviderInfo(MediaManager.MediaSession mediaSession)
|
||||
{
|
||||
if (!_mediaManager.IsStarted) return;
|
||||
if (mediaSession == null) return;
|
||||
|
||||
var id = mediaSession?.Id;
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
|
||||
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
var found = _settingsService.AppSettings.MediaSourceProvidersInfo.FirstOrDefault(x => x.Provider == id);
|
||||
if (found == null)
|
||||
{
|
||||
_settingsService.AppSettings.MediaSourceProvidersInfo.Add(new MediaSourceProviderInfo(id, _settingsService.AppSettings.GeneralSettings.ListenOnNewPlaybackSource));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void SendNullMessages()
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, (() =>
|
||||
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
CurrentSongInfo = SongInfoExtensions.Placeholder;
|
||||
CurrentIsPlaying = false;
|
||||
|
||||
CurrentMediaSourceProviderInfo = GetCurrentMediaSourceProviderInfo();
|
||||
|
||||
CurrentPosition = TimeSpan.Zero;
|
||||
|
||||
UpdateAlbumArt();
|
||||
UpdateLyrics();
|
||||
|
||||
_scrobbleTimer.Stop();
|
||||
_discordService.Disable();
|
||||
UpdateCurrentMediaSourceProviderInfoPositionOffset();
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateCurrentMediaSourceProviderInfoPositionOffset()
|
||||
@@ -475,21 +476,39 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTargetScrobbledDuration()
|
||||
{
|
||||
TargetScrobbledDuration = TimeSpan.FromSeconds(CurrentSongInfo.Duration == 0 ? 30 : CurrentSongInfo.Duration / 2);
|
||||
}
|
||||
|
||||
private async Task SendFocusedMessagesAsync()
|
||||
{
|
||||
GlobalSystemMediaTransportControlsSessionMediaProperties? mediaProps = null;
|
||||
|
||||
var desiredSession = GetCurrentSession();
|
||||
if (_currentDesiredSession == null)
|
||||
{
|
||||
SendNullMessages();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
mediaProps = await desiredSession?.ControlSession?.TryGetMediaPropertiesAsync();
|
||||
}
|
||||
catch (Exception) { }
|
||||
var mediaProps = await _currentDesiredSession.ControlSession?.TryGetMediaPropertiesAsync();
|
||||
var timelineProps = _currentDesiredSession.ControlSession?.GetTimelineProperties();
|
||||
var playbackInfo = _currentDesiredSession.ControlSession?.GetPlaybackInfo();
|
||||
|
||||
MediaManager_OnAnyTimelinePropertyChanged(desiredSession, desiredSession?.ControlSession?.GetTimelineProperties());
|
||||
MediaManager_OnAnyMediaPropertyChanged(desiredSession, mediaProps);
|
||||
MediaManager_OnAnyPlaybackStateChanged(desiredSession, desiredSession?.ControlSession?.GetPlaybackInfo());
|
||||
if (mediaProps == null || timelineProps == null || playbackInfo == null)
|
||||
{
|
||||
SendNullMessages();
|
||||
return;
|
||||
}
|
||||
|
||||
MediaManager_OnAnyTimelinePropertyChanged(_currentDesiredSession, timelineProps);
|
||||
MediaManager_OnAnyMediaPropertyChanged(_currentDesiredSession, mediaProps);
|
||||
MediaManager_OnAnyPlaybackStateChanged(_currentDesiredSession, playbackInfo);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
SendNullMessages();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartSSE()
|
||||
@@ -540,7 +559,7 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, async () =>
|
||||
{
|
||||
if (PlayerIDHelper.IsLXMusic(CurrentSongInfo?.PlayerId))
|
||||
if (PlayerIdHelper.IsLXMusic(CurrentSongInfo.PlayerId))
|
||||
{
|
||||
var data = JsonSerializer.Deserialize(e.Message, Serialization.SourceGenerationContext.Default.JsonElement);
|
||||
if (data.ValueKind == JsonValueKind.Number)
|
||||
@@ -551,11 +570,11 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
}
|
||||
else if (e.Event == "duration")
|
||||
{
|
||||
CurrentSongInfo?.DurationMs = data.GetDouble() * 1000;
|
||||
CurrentSongInfo.DurationMs = data.GetDouble() * 1000;
|
||||
UpdateDiscordPresence();
|
||||
}
|
||||
|
||||
if (IsMediaSourceTimelineSyncEnabled(CurrentSongInfo?.PlayerId))
|
||||
if (IsMediaSourceTimelineSyncEnabled(CurrentSongInfo.PlayerId))
|
||||
{
|
||||
CurrentPosition = TimeSpan.FromSeconds(_lxMusicPositionSeconds);
|
||||
}
|
||||
@@ -587,47 +606,27 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
|
||||
public async Task PlayAsync()
|
||||
{
|
||||
var desiredSession = GetCurrentSession();
|
||||
if (desiredSession != null)
|
||||
{
|
||||
await desiredSession.ControlSession?.TryPlayAsync();
|
||||
}
|
||||
await _currentDesiredSession?.ControlSession?.TryPlayAsync();
|
||||
}
|
||||
|
||||
public async Task PauseAsync()
|
||||
{
|
||||
var desiredSession = GetCurrentSession();
|
||||
if (desiredSession != null)
|
||||
{
|
||||
await desiredSession.ControlSession?.TryPauseAsync();
|
||||
}
|
||||
await _currentDesiredSession?.ControlSession?.TryPauseAsync();
|
||||
}
|
||||
|
||||
public async Task PreviousAsync()
|
||||
{
|
||||
var desiredSession = GetCurrentSession();
|
||||
if (desiredSession != null)
|
||||
{
|
||||
await desiredSession.ControlSession?.TrySkipPreviousAsync();
|
||||
}
|
||||
await _currentDesiredSession?.ControlSession?.TrySkipPreviousAsync();
|
||||
}
|
||||
|
||||
public async Task NextAsync()
|
||||
{
|
||||
var desiredSession = GetCurrentSession();
|
||||
if (desiredSession != null)
|
||||
{
|
||||
await desiredSession.ControlSession?.TrySkipNextAsync();
|
||||
}
|
||||
await _currentDesiredSession?.ControlSession?.TrySkipNextAsync();
|
||||
}
|
||||
|
||||
public async Task ChangePosition(double seconds)
|
||||
{
|
||||
var desiredSession = GetCurrentSession();
|
||||
if (desiredSession != null)
|
||||
{
|
||||
await desiredSession.ControlSession?.TryChangePlaybackPositionAsync(TimeSpan.FromSeconds(seconds).Ticks);
|
||||
}
|
||||
await _currentDesiredSession?.ControlSession?.TryChangePlaybackPositionAsync(TimeSpan.FromSeconds(seconds).Ticks);
|
||||
}
|
||||
|
||||
public async Task ChangeLyricsLine(int index)
|
||||
@@ -650,7 +649,7 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
{
|
||||
if (WindowHook.GetWindowHandle<NowPlayingWindow>() is IntPtr hwnd)
|
||||
{
|
||||
TaskbarList.SetProgressValue(hwnd, (ulong)value.TotalSeconds, (ulong)(CurrentSongInfo?.Duration ?? value.TotalSeconds));
|
||||
TaskbarList.SetProgressValue(hwnd, (ulong)value.TotalSeconds, (ulong)(CurrentSongInfo.Duration));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -660,7 +659,7 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
{
|
||||
if (message.PropertyName == nameof(MediaSourceProviderInfo.IsEnabled))
|
||||
{
|
||||
MediaManager_OnFocusedSessionChanged(null);
|
||||
OnDesiredSessionChanged();
|
||||
}
|
||||
}
|
||||
else if (message.Sender is TranslationSettings)
|
||||
@@ -690,7 +689,15 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
{
|
||||
if (message.PropertyName == nameof(MusicGallerySettings.LyricsWindowStatus.IsOpened))
|
||||
{
|
||||
MediaManager_OnFocusedSessionChanged(null);
|
||||
OnDesiredSessionChanged();
|
||||
}
|
||||
}
|
||||
else if (message.Sender is MediaFolder)
|
||||
{
|
||||
if (message.PropertyName == nameof(MediaFolder.IsEnabled))
|
||||
{
|
||||
UpdateAlbumArt();
|
||||
UpdateLyrics();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -726,5 +733,16 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
}
|
||||
}
|
||||
|
||||
public void Receive(PropertyChangedMessage<DateTime?> message)
|
||||
{
|
||||
if (message.Sender is MediaFolder)
|
||||
{
|
||||
if (message.PropertyName == nameof(MediaFolder.LastSyncTime))
|
||||
{
|
||||
UpdateAlbumArt();
|
||||
UpdateLyrics();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ using System.Threading.Tasks;
|
||||
using Windows.Storage.Streams;
|
||||
using Windows.UI;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
namespace BetterLyrics.WinUI3.Services.GSMTCService
|
||||
{
|
||||
public interface IMediaSessionsService : INotifyPropertyChanged
|
||||
/// <summary>
|
||||
/// Interface for GlobalSystemMediaTransportControlsSession Service
|
||||
/// </summary>
|
||||
public interface IGSMTCService : INotifyPropertyChanged
|
||||
{
|
||||
Task PlayAsync();
|
||||
Task PauseAsync();
|
||||
@@ -23,8 +26,12 @@ namespace BetterLyrics.WinUI3.Services.MediaSessionsService
|
||||
|
||||
MediaSourceProviderInfo? CurrentMediaSourceProviderInfo { get; }
|
||||
|
||||
bool IsScrobbled { get; }
|
||||
TimeSpan ScrobbledDuration { get; }
|
||||
TimeSpan TargetScrobbledDuration { get; }
|
||||
|
||||
bool CurrentIsPlaying { get; }
|
||||
SongInfo? CurrentSongInfo { get; }
|
||||
SongInfo CurrentSongInfo { get; }
|
||||
TimeSpan CurrentPosition { get; }
|
||||
LyricsData? CurrentLyricsData { get; }
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// 2025/6/23 by Zhe Fang
|
||||
|
||||
using BetterLyrics.WinUI3.Events;
|
||||
using System;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.LibWatcherService
|
||||
{
|
||||
public interface ILibWatcherService
|
||||
{
|
||||
event EventHandler<LibChangedEventArgs>? MusicLibraryFilesChanged;
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// 2025/6/23 by Zhe Fang
|
||||
|
||||
using BetterLyrics.WinUI3.Events;
|
||||
using BetterLyrics.WinUI3.Services.SettingsService;
|
||||
using BetterLyrics.WinUI3.ViewModels;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.LibWatcherService
|
||||
{
|
||||
public class LibWatcherService : BaseViewModel, IDisposable, ILibWatcherService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly Dictionary<string, FileSystemWatcher> _watchers = [];
|
||||
|
||||
public LibWatcherService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_settingsService.AppSettings.LocalMediaFolders.CollectionChanged += LocalMediaFolders_CollectionChanged;
|
||||
_settingsService.AppSettings.LocalMediaFolders.ItemPropertyChanged += LocalMediaFolders_ItemPropertyChanged;
|
||||
UpdateWatchers();
|
||||
}
|
||||
|
||||
private void LocalMediaFolders_ItemPropertyChanged(object? sender, Collections.ItemPropertyChangedEventArgs e)
|
||||
{
|
||||
UpdateWatchers();
|
||||
}
|
||||
|
||||
private void LocalMediaFolders_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
UpdateWatchers();
|
||||
}
|
||||
|
||||
public event EventHandler<LibChangedEventArgs>? MusicLibraryFilesChanged;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var watcher in _watchers.Values)
|
||||
{
|
||||
watcher.Dispose();
|
||||
}
|
||||
_watchers.Clear();
|
||||
}
|
||||
|
||||
private void UpdateWatchers()
|
||||
{
|
||||
var folders = _settingsService.AppSettings.LocalMediaFolders;
|
||||
// 移除不再监听的
|
||||
foreach (var key in _watchers.Keys.ToList())
|
||||
{
|
||||
if (!folders.Any(x => x.Path == key && x.IsEnabled && x.IsRealTimeWatchEnabled))
|
||||
{
|
||||
_watchers[key].Dispose();
|
||||
_watchers.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新的监听
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
if (!_watchers.ContainsKey(folder.Path) && Directory.Exists(folder.Path) && folder.IsEnabled && folder.IsRealTimeWatchEnabled)
|
||||
{
|
||||
var watcher = new FileSystemWatcher(folder.Path)
|
||||
{
|
||||
IncludeSubdirectories = true,
|
||||
EnableRaisingEvents = true,
|
||||
};
|
||||
watcher.Created += (s, e) => OnChanged(folder.Path, e);
|
||||
watcher.Changed += (s, e) => OnChanged(folder.Path, e);
|
||||
watcher.Deleted += (s, e) => OnChanged(folder.Path, e);
|
||||
watcher.Renamed += (s, e) => OnChanged(folder.Path, e);
|
||||
_watchers[folder.Path] = watcher;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnChanged(string folder, FileSystemEventArgs e)
|
||||
{
|
||||
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
MusicLibraryFilesChanged?.Invoke(
|
||||
this,
|
||||
new LibChangedEventArgs(folder, e.FullPath, e.ChangeType)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using BetterLyrics.WinUI3.Extensions;
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Providers;
|
||||
using BetterLyrics.WinUI3.Services.FileSystemService;
|
||||
using BetterLyrics.WinUI3.Services.SettingsService;
|
||||
using Lyricify.Lyrics.Helpers;
|
||||
using Lyricify.Lyrics.Searchers;
|
||||
@@ -28,11 +29,13 @@ namespace BetterLyrics.WinUI3.Services.LyricsSearchService
|
||||
private readonly AppleMusic _appleMusic;
|
||||
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFileSystemService _fileSystemService;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public LyricsSearchService(ISettingsService settingsService, ILogger<LyricsSearchService> logger)
|
||||
public LyricsSearchService(ISettingsService settingsService, IFileSystemService fileSystemService, ILogger<LyricsSearchService> logger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_fileSystemService = fileSystemService;
|
||||
_logger = logger;
|
||||
|
||||
_lrcLibHttpClient = new();
|
||||
@@ -276,92 +279,50 @@ namespace BetterLyrics.WinUI3.Services.LyricsSearchService
|
||||
{
|
||||
int maxScore = 0;
|
||||
|
||||
MediaFolder? bestFolder = null;
|
||||
string? bestFilePath = null;
|
||||
FilesIndexItem? bestFileEntity = null;
|
||||
MediaFolder? bestFolderConfig = null;
|
||||
|
||||
var lyricsSearchResult = new LyricsSearchResult();
|
||||
|
||||
if (format.ToLyricsSearchProvider() is LyricsSearchProvider lyricsSearchProvider)
|
||||
{
|
||||
lyricsSearchResult.Provider = lyricsSearchProvider;
|
||||
}
|
||||
|
||||
foreach (var folder in _settingsService.AppSettings.LocalMediaFolders)
|
||||
string targetExt = format.ToFileExtension();
|
||||
|
||||
var enabledFolders = _settingsService.AppSettings.LocalMediaFolders
|
||||
.Where(f => f.IsEnabled)
|
||||
.ToList();
|
||||
|
||||
var enabledIds = enabledFolders.Select(f => f.Id).ToList();
|
||||
|
||||
if (enabledIds.Count == 0) return lyricsSearchResult;
|
||||
|
||||
var allFiles = await _fileSystemService.GetParsedFilesAsync(enabledIds);
|
||||
allFiles = allFiles.Where(x => FileHelper.LyricExtensions.Contains(Path.GetExtension(x.FileName))).ToList();
|
||||
|
||||
foreach (var item in allFiles)
|
||||
{
|
||||
if (!folder.IsEnabled) continue;
|
||||
|
||||
try
|
||||
if (item.FileName.EndsWith(targetExt, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using var fs = folder.CreateFileSystem();
|
||||
if (fs == null) continue;
|
||||
if (!await fs.ConnectAsync()) continue;
|
||||
int score = MetadataComparer.CalculateScore(songInfo, new LyricsSearchResult { Reference = item.FileName });
|
||||
|
||||
// 递归扫描
|
||||
var foldersToScan = new Queue<string>();
|
||||
foldersToScan.Enqueue(""); // 从根目录开始
|
||||
|
||||
string targetExt = format.ToFileExtension();
|
||||
|
||||
while (foldersToScan.Count > 0)
|
||||
if (score > maxScore)
|
||||
{
|
||||
var currentPath = foldersToScan.Dequeue();
|
||||
var items = await fs.GetFilesAsync(currentPath);
|
||||
maxScore = score;
|
||||
bestFileEntity = item;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.IsFolder)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
bestFolderConfig = enabledFolders.FirstOrDefault(f => f.Id == item.MediaFolderId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 日志记录...
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 如果找到了最佳匹配,读取内容
|
||||
if (bestFolder != null && bestFilePath != null)
|
||||
if (bestFileEntity != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 重新连接以读取文件 (因为之前的 fs 已经在 using 结束时释放)
|
||||
using var fs = bestFolder.CreateFileSystem();
|
||||
if (fs != null && await fs.ConnectAsync())
|
||||
{
|
||||
using var stream = await fs.OpenReadAsync(bestFilePath);
|
||||
lyricsSearchResult.Raw = bestFileEntity.EmbeddedLyrics;
|
||||
|
||||
// 使用 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)
|
||||
{
|
||||
// 读取失败处理
|
||||
}
|
||||
lyricsSearchResult.Reference = bestFileEntity.Uri;
|
||||
lyricsSearchResult.MatchPercentage = maxScore;
|
||||
}
|
||||
|
||||
return lyricsSearchResult;
|
||||
@@ -369,107 +330,53 @@ namespace BetterLyrics.WinUI3.Services.LyricsSearchService
|
||||
|
||||
private async Task<LyricsSearchResult> SearchEmbedded(SongInfo songInfo)
|
||||
{
|
||||
int bestScore = 0;
|
||||
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,
|
||||
};
|
||||
|
||||
foreach (var folder in _settingsService.AppSettings.LocalMediaFolders)
|
||||
var enabledIds = _settingsService.AppSettings.LocalMediaFolders
|
||||
.Where(f => f.IsEnabled)
|
||||
.Select(f => f.Id)
|
||||
.ToList();
|
||||
|
||||
if (enabledIds.Count == 0) return lyricsSearchResult;
|
||||
|
||||
var allFiles = await _fileSystemService.GetParsedFilesAsync(enabledIds);
|
||||
allFiles = allFiles.Where(x => FileHelper.MusicExtensions.Contains(Path.GetExtension(x.FileName))).ToList();
|
||||
|
||||
FilesIndexItem? bestFile = null;
|
||||
int maxScore = 0;
|
||||
|
||||
foreach (var item in allFiles)
|
||||
{
|
||||
if (!folder.IsEnabled) continue;
|
||||
if (string.IsNullOrEmpty(item.EmbeddedLyrics)) continue;
|
||||
|
||||
try
|
||||
int score = MetadataComparer.CalculateScore(songInfo, new LyricsSearchResult
|
||||
{
|
||||
using var fs = folder.CreateFileSystem();
|
||||
if (fs == null) continue;
|
||||
if (!await fs.ConnectAsync()) continue;
|
||||
Title = item.Title,
|
||||
Artists = item.Artists?.Split(ATL.Settings.DisplayValueSeparator),
|
||||
Album = item.Album,
|
||||
Duration = item.Duration
|
||||
});
|
||||
|
||||
var foldersToScan = new Queue<string>();
|
||||
foldersToScan.Enqueue("");
|
||||
|
||||
while (foldersToScan.Count > 0)
|
||||
{
|
||||
var currentPath = foldersToScan.Dequeue();
|
||||
var items = await fs.GetFilesAsync(currentPath);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
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);
|
||||
|
||||
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 (score > maxScore)
|
||||
{
|
||||
// 文件夹扫描失败忽略
|
||||
maxScore = score;
|
||||
bestFile = item;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestFilePath != null)
|
||||
if (bestFile != null && maxScore > 0)
|
||||
{
|
||||
// 直接使用缓存的数据,不需要 new Track(bestFile) 了
|
||||
lyricsSearchResult.Title = bestTitle;
|
||||
lyricsSearchResult.Artists = bestArtists;
|
||||
lyricsSearchResult.Album = bestAlbum;
|
||||
lyricsSearchResult.Duration = bestDuration;
|
||||
lyricsSearchResult.Title = bestFile.Title;
|
||||
lyricsSearchResult.Artists = bestFile.Artists?.Split(ATL.Settings.DisplayValueSeparator);
|
||||
lyricsSearchResult.Album = bestFile.Album;
|
||||
lyricsSearchResult.Duration = bestFile.Duration;
|
||||
|
||||
lyricsSearchResult.Raw = bestRaw;
|
||||
lyricsSearchResult.Reference = bestFilePath;
|
||||
lyricsSearchResult.MatchPercentage = bestScore;
|
||||
lyricsSearchResult.Raw = bestFile.EmbeddedLyrics;
|
||||
lyricsSearchResult.Reference = bestFile.Uri;
|
||||
lyricsSearchResult.MatchPercentage = maxScore;
|
||||
}
|
||||
|
||||
return lyricsSearchResult;
|
||||
@@ -651,11 +558,11 @@ namespace BetterLyrics.WinUI3.Services.LyricsSearchService
|
||||
|
||||
ISearchResult? result;
|
||||
|
||||
if (songInfo.SongId != null && searcher == Searchers.Netease && PlayerIDHelper.IsNeteaseFamily(songInfo.PlayerId))
|
||||
if (songInfo.SongId != null && searcher == Searchers.Netease && PlayerIdHelper.IsNeteaseFamily(songInfo.PlayerId))
|
||||
{
|
||||
result = new NeteaseSearchResult(songInfo.Title, songInfo.Artists, songInfo.Album, [], (int)songInfo.DurationMs, songInfo.SongId);
|
||||
}
|
||||
else if (songInfo.SongId != null && searcher == Searchers.QQMusic && songInfo.PlayerId == Constants.PlayerID.QQMusic)
|
||||
else if (songInfo.SongId != null && searcher == Searchers.QQMusic && songInfo.PlayerId == Constants.PlayerId.QQMusic)
|
||||
{
|
||||
result = new QQMusicSearchResult(songInfo.Title, songInfo.Artists, songInfo.Album, [], (int)songInfo.DurationMs, songInfo.SongId, "");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Models.Stats;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.PlayHistoryService
|
||||
{
|
||||
public interface IPlayHistoryService
|
||||
{
|
||||
Task AddLogAsync(PlayHistoryItem item);
|
||||
Task<List<PlayHistoryItem>> GetRecentLogsAsync(int limit = 50);
|
||||
Task<List<PlayHistoryItem>> GetLogsByDateRangeAsync(DateTime start, DateTime end);
|
||||
|
||||
Task<List<SongPlayCount>> GetTopSongsAsync(DateTime start, DateTime end, int limit = 10);
|
||||
Task<List<ArtistPlayCount>> GetTopArtistsAsync(DateTime start, DateTime end, int limit = 10);
|
||||
Task<TimeSpan> GetTotalListeningDurationAsync(DateTime start, DateTime end);
|
||||
Task<List<PlayerStats>> GetPlayerDistributionAsync(DateTime start, DateTime end);
|
||||
|
||||
Task DeleteLogAsync(int id);
|
||||
Task ClearHistoryAsync();
|
||||
Task GenerateTestDataAsync(int count = 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using BetterLyrics.WinUI3.Constants;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Models.Db;
|
||||
using BetterLyrics.WinUI3.Models.Stats;
|
||||
using BetterLyrics.WinUI3.ViewModels;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.PlayHistoryService
|
||||
{
|
||||
public class PlayHistoryService : IPlayHistoryService
|
||||
{
|
||||
private readonly IDbContextFactory<PlayHistoryDbContext> _contextFactory;
|
||||
|
||||
public PlayHistoryService(IDbContextFactory<PlayHistoryDbContext> contextFactory)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
}
|
||||
|
||||
public async Task AddLogAsync(PlayHistoryItem item)
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 确保 UTC
|
||||
if (item.StartedAt.Kind != DateTimeKind.Utc)
|
||||
{
|
||||
item.StartedAt = item.StartedAt.ToUniversalTime();
|
||||
}
|
||||
|
||||
context.PlayHistory.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<List<PlayHistoryItem>> GetRecentLogsAsync(int limit = 50)
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
return await context.PlayHistory
|
||||
.AsNoTracking() // 读操作,不需要追踪状态,提升性能
|
||||
.OrderByDescending(x => x.StartedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<PlayHistoryItem>> GetLogsByDateRangeAsync(DateTime start, DateTime end)
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
return await context.PlayHistory
|
||||
.AsNoTracking()
|
||||
.Where(x => x.StartedAt >= start && x.StartedAt <= end)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<SongPlayCount>> GetTopSongsAsync(DateTime start, DateTime end, int limit = 10)
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// EF Core 会自动将这个 LINQ 翻译成高效的 GROUP BY SQL
|
||||
return await context.PlayHistory
|
||||
.AsNoTracking()
|
||||
.Where(x => x.StartedAt >= start && x.StartedAt <= end)
|
||||
.GroupBy(x => new { x.Title, x.Artist }) // 组合分组
|
||||
.Select(g => new SongPlayCount
|
||||
{
|
||||
Title = g.Key.Title,
|
||||
Artist = g.Key.Artist,
|
||||
PlayCount = g.Count()
|
||||
})
|
||||
.OrderByDescending(x => x.PlayCount)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<ArtistPlayCount>> GetTopArtistsAsync(DateTime start, DateTime end, int limit = 10)
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
return await context.PlayHistory
|
||||
.AsNoTracking()
|
||||
.Where(x => x.StartedAt >= start && x.StartedAt <= end)
|
||||
.GroupBy(x => x.Artist)
|
||||
.Select(g => new ArtistPlayCount
|
||||
{
|
||||
Artist = g.Key,
|
||||
PlayCount = g.Count(),
|
||||
// 注意:SQLite 存储 double 精度,这里求和后转秒
|
||||
TotalDurationSeconds = g.Sum(x => x.DurationPlayedMs) / 1000.0
|
||||
})
|
||||
.OrderByDescending(x => x.PlayCount)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<TimeSpan> GetTotalListeningDurationAsync(DateTime start, DateTime end)
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
var totalMs = await context.PlayHistory
|
||||
.Where(x => x.StartedAt >= start && x.StartedAt <= end)
|
||||
.SumAsync(x => Math.Min(x.DurationPlayedMs, x.TotalDurationMs)); // 防止超过歌曲本身时长
|
||||
|
||||
return TimeSpan.FromMilliseconds(totalMs);
|
||||
}
|
||||
|
||||
public async Task<List<PlayerStats>> GetPlayerDistributionAsync(DateTime start, DateTime end)
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
return await context.PlayHistory
|
||||
.AsNoTracking()
|
||||
.Where(x => x.StartedAt >= start && x.StartedAt <= end)
|
||||
.GroupBy(x => x.PlayerId)
|
||||
.Select(g => new PlayerStats
|
||||
{
|
||||
PlayerId = g.Key,
|
||||
Count = g.Count()
|
||||
})
|
||||
.OrderByDescending(x => x.Count)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteLogAsync(int id)
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// EF Core 删除需要先查询,或者使用 ExecuteDeleteAsync (EF Core 7+)
|
||||
// 写法 1 (传统):
|
||||
// var item = await context.PlayHistory.FindAsync(id);
|
||||
// if (item != null) { context.PlayHistory.Remove(item); await context.SaveChangesAsync(); }
|
||||
|
||||
// 写法 2 (EF Core 7.0+ 高效写法,直接生成 DELETE SQL):
|
||||
await context.PlayHistory
|
||||
.Where(x => x.Id == id)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
public async Task ClearHistoryAsync()
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
|
||||
// 高效清空表
|
||||
await context.PlayHistory.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
public async Task GenerateTestDataAsync(int count = 100)
|
||||
{
|
||||
// 这里的逻辑稍微重构了一下,使用批量插入提升性能
|
||||
var random = new Random();
|
||||
var presetSongs = new List<(string Title, string Artist, string Album)>
|
||||
{
|
||||
("Anti-Hero", "Taylor Swift", "Midnights"),
|
||||
("Cruel Summer", "Taylor Swift", "Lover"),
|
||||
("Blank Space", "Taylor Swift", "1989"),
|
||||
("As It Was", "Harry Styles", "Harry's House"),
|
||||
("Late Night Talking", "Harry Styles", "Harry's House"),
|
||||
("Die For You", "The Weeknd", "Starboy"),
|
||||
("Blinding Lights", "The Weeknd", "After Hours"),
|
||||
("Starboy", "The Weeknd", "Starboy"),
|
||||
("Shape of You", "Ed Sheeran", "Divide"),
|
||||
("Bad Guy", "Billie Eilish", "When We All Fall Asleep, Where Do We Go?"),
|
||||
("Flowers", "Miley Cyrus", "Endless Summer Vacation"),
|
||||
("Stay", "The Kid LAROI & Justin Bieber", "F*ck Love 3: Over You"),
|
||||
("七里香", "周杰伦", "七里香"),
|
||||
("晴天", "周杰伦", "叶惠美"),
|
||||
("一路向北", "周杰伦", "11月的肖邦"),
|
||||
("告白气球", "周杰伦", "周杰伦的床边故事"),
|
||||
("十年", "陈奕迅", "黑·白·灰"),
|
||||
("富士山下", "陈奕迅", "What's Going On...?"),
|
||||
("孤勇者", "陈奕迅", "孤勇者"),
|
||||
("修炼爱情", "林俊杰", "因你而在"),
|
||||
("江南", "林俊杰", "第二天堂"),
|
||||
("光年之外", "G.E.M. 邓紫棋", "摩天动物园"),
|
||||
("泡沫", "G.E.M. 邓紫棋", "Xposed"),
|
||||
("因为爱情", "王菲 & 陈奕迅", "Stranger Under My Skin"),
|
||||
("红豆", "王菲", "唱游"),
|
||||
("Bohemian Rhapsody", "Queen", "A Night at the Opera"),
|
||||
("Don't Stop Me Now", "Queen", "Jazz"),
|
||||
("Numb", "Linkin Park", "Meteora"),
|
||||
("In the End", "Linkin Park", "Hybrid Theory"),
|
||||
("Yellow", "Coldplay", "Parachutes"),
|
||||
("Viva La Vida", "Coldplay", "Viva La Vida"),
|
||||
("Smells Like Teen Spirit", "Nirvana", "Nevermind"),
|
||||
("Hotel California", "Eagles", "Hotel California"),
|
||||
("Lemon", "米津玄師", "Lemon"),
|
||||
("Kick Back", "米津玄師", "KICK BACK"),
|
||||
("アイドル", "YOASOBI", "アイドル"),
|
||||
("夜に駆ける", "YOASOBI", "THE BOOK"),
|
||||
("First Love", "宇多田ヒカル", "First Love"),
|
||||
("Dynamite", "BTS", "BE"),
|
||||
("Butter", "BTS", "Butter"),
|
||||
("How You Like That", "BLACKPINK", "The Album"),
|
||||
("Ditto", "NewJeans", "OMG"),
|
||||
("Get Lucky", "Daft Punk", "Random Access Memories"),
|
||||
("The Nights", "Avicii", "The Days / Nights"),
|
||||
("Summer", "Calvin Harris", "Motion"),
|
||||
};
|
||||
|
||||
var playerIds = new[]
|
||||
{
|
||||
PlayerId.Spotify, PlayerId.Spotify, PlayerId.Spotify,
|
||||
PlayerId.MusicBee, PlayerId.MusicBee,
|
||||
PlayerId.QQMusic,
|
||||
PlayerId.NetEaseCloudMusic,
|
||||
PlayerId.AppleMusic,
|
||||
};
|
||||
|
||||
var batchList = new List<PlayHistoryItem>();
|
||||
|
||||
// 我们尝试生成 count 条有效数据
|
||||
// 为了防止死循环,加个硬上限
|
||||
int attempts = 0;
|
||||
while (batchList.Count < count && attempts < count * 5)
|
||||
{
|
||||
attempts++;
|
||||
var song = presetSongs[random.Next(presetSongs.Count)];
|
||||
var playerId = playerIds[random.Next(playerIds.Length)];
|
||||
|
||||
var daysBack = random.Next(0, 365);
|
||||
var hoursBack = random.Next(0, 24);
|
||||
var minutesBack = random.Next(0, 60);
|
||||
var secondsBack = random.Next(0, 60);
|
||||
|
||||
var startedAt = DateTime.UtcNow // 直接用 UTC
|
||||
.AddDays(-daysBack)
|
||||
.AddHours(-hoursBack)
|
||||
.AddMinutes(-minutesBack)
|
||||
.AddSeconds(-secondsBack);
|
||||
|
||||
var totalDurationMs = random.Next(180, 300) * 1000.0;
|
||||
double playedRatio;
|
||||
double roll = random.NextDouble();
|
||||
|
||||
if (roll > 0.3) playedRatio = 0.9 + (random.NextDouble() * 0.1);
|
||||
else if (roll > 0.1) playedRatio = 0.3 + (random.NextDouble() * 0.5);
|
||||
else playedRatio = 0.05 + (random.NextDouble() * 0.25);
|
||||
|
||||
var playedDurationMs = totalDurationMs * playedRatio;
|
||||
|
||||
// 只有听了一半以上的才算作记录
|
||||
if (playedDurationMs >= (totalDurationMs / 2))
|
||||
{
|
||||
batchList.Add(new PlayHistoryItem
|
||||
{
|
||||
Title = song.Title,
|
||||
Artist = song.Artist,
|
||||
Album = song.Album,
|
||||
PlayerId = playerId,
|
||||
StartedAt = startedAt,
|
||||
TotalDurationMs = totalDurationMs,
|
||||
DurationPlayedMs = playedDurationMs
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (batchList.Count > 0)
|
||||
{
|
||||
using var context = await _contextFactory.CreateDbContextAsync();
|
||||
await context.PlayHistory.AddRangeAsync(batchList);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.SMTCService
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for SystemMediaTransportControlsSession Service
|
||||
/// </summary>
|
||||
public interface ISMTCService
|
||||
{
|
||||
public ObservableCollection<PlayQueueItem> TrackPlayingQueue { get; set; }
|
||||
public ExtendedTrack? PlayingTrack { get; set; }
|
||||
|
||||
Task PlayTrackAsync(PlayQueueItem? playQueueItem);
|
||||
Task PlayTrackAtAsync(int index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using BetterLyrics.WinUI3.Constants;
|
||||
using BetterLyrics.WinUI3.Enums;
|
||||
using BetterLyrics.WinUI3.Extensions;
|
||||
using BetterLyrics.WinUI3.Helper;
|
||||
using BetterLyrics.WinUI3.Models;
|
||||
using BetterLyrics.WinUI3.Models.Settings;
|
||||
using BetterLyrics.WinUI3.Services.FileSystemService;
|
||||
using BetterLyrics.WinUI3.Services.GSMTCService;
|
||||
using BetterLyrics.WinUI3.Services.SettingsService;
|
||||
using BetterLyrics.WinUI3.ViewModels;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.ApplicationModel;
|
||||
using Windows.Media;
|
||||
using Windows.Media.Core;
|
||||
using Windows.Media.Playback;
|
||||
using Windows.Storage;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace BetterLyrics.WinUI3.Services.SMTCService
|
||||
{
|
||||
public partial class SMTCService : BaseViewModel, ISMTCService
|
||||
{
|
||||
private readonly MediaPlayer _mediaPlayer;
|
||||
private readonly MediaTimelineController _timelineController;
|
||||
private readonly SystemMediaTransportControls _smtc;
|
||||
|
||||
private IRandomAccessStream? _currentStream;
|
||||
private Stream? _currentNetStream;
|
||||
private IUnifiedFileSystem? _currentProvider;
|
||||
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IFileSystemService _fileSystemService;
|
||||
|
||||
[ObservableProperty] public partial ObservableCollection<PlayQueueItem> TrackPlayingQueue { get; set; } = [];
|
||||
[ObservableProperty] public partial ExtendedTrack? PlayingTrack { get; set; }
|
||||
|
||||
public SMTCService(ISettingsService settingsService, IFileSystemService fileSystemService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_fileSystemService = fileSystemService;
|
||||
|
||||
_mediaPlayer = new MediaPlayer();
|
||||
_mediaPlayer.MediaOpened += MediaPlayer_MediaOpened;
|
||||
_mediaPlayer.MediaEnded += MediaPlayer_MediaEnded;
|
||||
_mediaPlayer.CommandManager.IsEnabled = false;
|
||||
|
||||
_timelineController = _mediaPlayer.TimelineController = new();
|
||||
_timelineController.PositionChanged += TimelineController_PositionChanged;
|
||||
|
||||
_smtc = _mediaPlayer.SystemMediaTransportControls;
|
||||
_smtc.IsPlayEnabled = true;
|
||||
_smtc.IsPauseEnabled = true;
|
||||
_smtc.IsNextEnabled = true;
|
||||
_smtc.IsPreviousEnabled = true;
|
||||
_smtc.ButtonPressed += Smtc_ButtonPressed;
|
||||
_smtc.PlaybackPositionChangeRequested += Smtc_PlaybackPositionChangeRequested;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var parsedFiles = await _fileSystemService.GetParsedFilesAsync();
|
||||
var playQueue = _settingsService.AppSettings.MusicGallerySettings.PlayQueuePaths
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x =>
|
||||
{
|
||||
var encodedUri = new Uri(x).AbsoluteUri;
|
||||
return new PlayQueueItem(new ExtendedTrack(parsedFiles.FirstOrDefault(y => y.Uri == encodedUri)));
|
||||
});
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
TrackPlayingQueue = [.. playQueue];
|
||||
TrackPlayingQueue.CollectionChanged += TrackPlayingQueue_CollectionChanged;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void Smtc_ButtonPressed(SystemMediaTransportControls sender, SystemMediaTransportControlsButtonPressedEventArgs args)
|
||||
{
|
||||
switch (args.Button)
|
||||
{
|
||||
case SystemMediaTransportControlsButton.Play:
|
||||
_smtc.PlaybackStatus = MediaPlaybackStatus.Playing;
|
||||
_timelineController.Resume();
|
||||
break;
|
||||
case SystemMediaTransportControlsButton.Pause:
|
||||
_smtc.PlaybackStatus = MediaPlaybackStatus.Paused;
|
||||
_timelineController.Pause();
|
||||
break;
|
||||
case SystemMediaTransportControlsButton.Next:
|
||||
PlayNextTrack();
|
||||
break;
|
||||
case SystemMediaTransportControlsButton.Previous:
|
||||
PlayPreviousTrack();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void Smtc_PlaybackPositionChangeRequested(SystemMediaTransportControls sender, PlaybackPositionChangeRequestedEventArgs args)
|
||||
{
|
||||
_timelineController.Position = args.RequestedPlaybackPosition;
|
||||
}
|
||||
|
||||
private void MediaPlayer_MediaOpened(MediaPlayer sender, object args)
|
||||
{
|
||||
_timelineController.Start();
|
||||
_smtc.PlaybackStatus = MediaPlaybackStatus.Playing;
|
||||
}
|
||||
|
||||
private void MediaPlayer_MediaEnded(MediaPlayer sender, object args)
|
||||
{
|
||||
PlayNextTrack();
|
||||
}
|
||||
|
||||
private void TimelineController_PositionChanged(MediaTimelineController sender, object args)
|
||||
{
|
||||
_smtc.UpdateTimelineProperties(new SystemMediaTransportControlsTimelineProperties()
|
||||
{
|
||||
Position = sender.Position,
|
||||
EndTime = _mediaPlayer.PlaybackSession.NaturalDuration
|
||||
});
|
||||
}
|
||||
|
||||
private void TrackPlayingQueue_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
_settingsService.AppSettings.MusicGallerySettings.PlayQueuePaths = [.. TrackPlayingQueue.Select(x => x.Track.Uri.ToDecodedAbsoluteUri())];
|
||||
}
|
||||
|
||||
private string GetMimeType(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLower();
|
||||
return ext switch
|
||||
{
|
||||
".mp3" => "audio/mpeg",
|
||||
".flac" => "audio/flac",
|
||||
".wav" => "audio/wav",
|
||||
".m4a" => "audio/mp4",
|
||||
".aac" => "audio/aac",
|
||||
".ogg" => "audio/ogg",
|
||||
".wma" => "audio/x-ms-wma",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
private void PlayNextTrack()
|
||||
{
|
||||
var musicGallerySettings = _settingsService.AppSettings.MusicGallerySettings;
|
||||
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, async () =>
|
||||
{
|
||||
switch (musicGallerySettings.PlaybackOrder)
|
||||
{
|
||||
case PlaybackOrder.RepeatAll:
|
||||
if (musicGallerySettings.PlayQueueIndex < TrackPlayingQueue.Count - 1)
|
||||
{
|
||||
musicGallerySettings.PlayQueueIndex++;
|
||||
}
|
||||
else
|
||||
{
|
||||
musicGallerySettings.PlayQueueIndex = 0;
|
||||
}
|
||||
break;
|
||||
case PlaybackOrder.RepeatOne:
|
||||
//_timelineController.Position = TimeSpan.Zero;
|
||||
break;
|
||||
case PlaybackOrder.Shuffle:
|
||||
if (TrackPlayingQueue.Count > 0)
|
||||
{
|
||||
musicGallerySettings.PlayQueueIndex = new Random().Next(0, TrackPlayingQueue.Count);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
await PlayTrackAtAsync(musicGallerySettings.PlayQueueIndex);
|
||||
});
|
||||
}
|
||||
|
||||
private void PlayPreviousTrack()
|
||||
{
|
||||
var musicGallerySettings = _settingsService.AppSettings.MusicGallerySettings;
|
||||
_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, async () =>
|
||||
{
|
||||
switch (musicGallerySettings.PlaybackOrder)
|
||||
{
|
||||
case PlaybackOrder.RepeatAll:
|
||||
if (musicGallerySettings.PlayQueueIndex > 0)
|
||||
{
|
||||
musicGallerySettings.PlayQueueIndex--;
|
||||
}
|
||||
else
|
||||
{
|
||||
musicGallerySettings.PlayQueueIndex = TrackPlayingQueue.Count - 1;
|
||||
}
|
||||
break;
|
||||
case PlaybackOrder.RepeatOne:
|
||||
//_timelineController.Position = TimeSpan.Zero;
|
||||
break;
|
||||
case PlaybackOrder.Shuffle:
|
||||
if (TrackPlayingQueue.Count > 0)
|
||||
{
|
||||
musicGallerySettings.PlayQueueIndex = new Random().Next(0, TrackPlayingQueue.Count);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
await PlayTrackAtAsync(musicGallerySettings.PlayQueueIndex);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task PlayTrackAsync(PlayQueueItem? playQueueItem)
|
||||
{
|
||||
_timelineController.Pause();
|
||||
_mediaPlayer.Source = null;
|
||||
|
||||
// 清理旧资源
|
||||
_currentStream?.Dispose();
|
||||
_currentNetStream?.Dispose();
|
||||
_currentStream = null;
|
||||
_currentNetStream = null;
|
||||
|
||||
if (playQueueItem == null)
|
||||
{
|
||||
_smtc.IsEnabled = false;
|
||||
_smtc.DisplayUpdater.ClearAll();
|
||||
}
|
||||
else
|
||||
{
|
||||
PlayingTrack = playQueueItem.Track;
|
||||
_smtc.IsEnabled = true;
|
||||
|
||||
try
|
||||
{
|
||||
var targetFolder = _settingsService.AppSettings.LocalMediaFolders.FirstOrDefault(f =>
|
||||
{
|
||||
var fUri = f.GetStandardUri().AbsoluteUri;
|
||||
return PlayingTrack.Uri.StartsWith(fUri, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
|
||||
if (targetFolder == null)
|
||||
{
|
||||
throw new FileNotFoundException(null, PlayingTrack.Uri.ToDecodedAbsoluteUri());
|
||||
}
|
||||
|
||||
_currentProvider = targetFolder.CreateFileSystem();
|
||||
if (_currentProvider == null) return;
|
||||
|
||||
await _currentProvider.ConnectAsync();
|
||||
|
||||
var fileCacheStub = new FilesIndexItem
|
||||
{
|
||||
Uri = PlayingTrack.Uri
|
||||
};
|
||||
|
||||
var sourceStream = await _fileSystemService.OpenFileAsync(_currentProvider, fileCacheStub);
|
||||
|
||||
if (sourceStream == null)
|
||||
{
|
||||
throw new FileNotFoundException(null, fileCacheStub.Uri);
|
||||
}
|
||||
|
||||
if (sourceStream.CanSeek)
|
||||
{
|
||||
_currentNetStream = sourceStream;
|
||||
}
|
||||
else
|
||||
{
|
||||
var memStream = new MemoryStream();
|
||||
|
||||
await sourceStream.CopyToAsync(memStream);
|
||||
memStream.Position = 0;
|
||||
|
||||
sourceStream.Dispose();
|
||||
|
||||
_currentNetStream = memStream;
|
||||
}
|
||||
|
||||
_currentStream = _currentNetStream.AsRandomAccessStream();
|
||||
|
||||
string contentType = GetMimeType(PlayingTrack.FileName);
|
||||
var mediaSource = MediaSource.CreateFromStream(_currentStream, contentType);
|
||||
|
||||
_mediaPlayer.Source = mediaSource;
|
||||
|
||||
var updater = _smtc.DisplayUpdater;
|
||||
updater.Type = MediaPlaybackType.Music;
|
||||
|
||||
updater.MusicProperties.Title = PlayingTrack.Title ?? PlayingTrack.FileName;
|
||||
updater.MusicProperties.Artist = PlayingTrack.Artist ?? "";
|
||||
updater.MusicProperties.AlbumTitle = PlayingTrack.Album ?? "";
|
||||
|
||||
updater.MusicProperties.Genres.Clear();
|
||||
updater.MusicProperties.Genres.Add($"{ExtendedGenreFiled.FileName}{Path.GetFileNameWithoutExtension(PlayingTrack.FileName)}");
|
||||
|
||||
updater.AppMediaId = Package.Current.Id.FullName;
|
||||
|
||||
if (!string.IsNullOrEmpty(PlayingTrack.LocalAlbumArtPath) && File.Exists(PlayingTrack.LocalAlbumArtPath))
|
||||
{
|
||||
var storageFile = await StorageFile.GetFileFromPathAsync(PlayingTrack.LocalAlbumArtPath);
|
||||
updater.Thumbnail = RandomAccessStreamReference.CreateFromFile(storageFile);
|
||||
}
|
||||
else
|
||||
{
|
||||
updater.Thumbnail = null;
|
||||
}
|
||||
|
||||
updater.Update();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ToastHelper.ShowToast("Error", ex.Message, InfoBarSeverity.Error);
|
||||
_timelineController.Pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PlayTrackAtAsync(int index)
|
||||
{
|
||||
await PlayTrackAsync(TrackPlayingQueue.ElementAtOrDefault(index));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,13 @@ namespace BetterLyrics.WinUI3.Services.SettingsService
|
||||
public partial class SettingsService : BaseViewModel, ISettingsService
|
||||
{
|
||||
private readonly DispatcherQueueTimer _writeAppSettingsTimer;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
|
||||
public AppSettings AppSettings { get; set; }
|
||||
|
||||
public SettingsService()
|
||||
public SettingsService(ILocalizationService localizationService)
|
||||
{
|
||||
_localizationService = localizationService;
|
||||
_writeAppSettingsTimer = _dispatcherQueue.CreateTimer();
|
||||
|
||||
AppSettings = ReadAppSettings();
|
||||
@@ -60,6 +62,7 @@ namespace BetterLyrics.WinUI3.Services.SettingsService
|
||||
AppSettings.Version = MetadataHelper.AppVersion;
|
||||
|
||||
EnsureMediaSourceProvidersInfo();
|
||||
EnsureStarredPlaylists();
|
||||
}
|
||||
|
||||
private void EnsureMediaSourceProvidersInfo()
|
||||
@@ -102,6 +105,20 @@ namespace BetterLyrics.WinUI3.Services.SettingsService
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureStarredPlaylists()
|
||||
{
|
||||
if (!AppSettings.StarredPlaylists.Any(x => x.IsDefault))
|
||||
{
|
||||
AppSettings.StarredPlaylists.Insert(0, new SongsTabInfo
|
||||
{
|
||||
Name = _localizationService.GetLocalizedString("MusicGalleryPageAllSongs"),
|
||||
Icon = "\uE8A9",
|
||||
FilterProperty = CommonSongProperty.Title,
|
||||
FilterValue = string.Empty
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void AppSettings_ItemPropertyChanged(object? sender, ItemPropertyChangedEventArgs e)
|
||||
{
|
||||
WriteAppSettings();
|
||||
|
||||
@@ -129,21 +129,12 @@
|
||||
<data name="AlbumArtSearchSMTCProvider" xml:space="preserve">
|
||||
<value>مشغل الموسيقى</value>
|
||||
</data>
|
||||
<data name="AllLyricsSettingsControlPictureInPicture.Content" xml:space="preserve">
|
||||
<value>وضع صورة داخل صورة (PiP)</value>
|
||||
</data>
|
||||
<data name="AppSettingsControlGeneral.Text" xml:space="preserve">
|
||||
<value>النافذة</value>
|
||||
</data>
|
||||
<data name="ArtistsSplitHint.Text" xml:space="preserve">
|
||||
<value>عند إدخال فنانين متعددين، يرجى استخدام أحد الفواصل التالية للفصل بينهم (لا تخلط بينها)</value>
|
||||
</data>
|
||||
<data name="BaseWindowHostInfoBarCheckBox.Content" xml:space="preserve">
|
||||
<value>لا تظهر هذه الرسالة مرة أخرى</value>
|
||||
</data>
|
||||
<data name="BaseWindowMiniFlyoutItem.Text" xml:space="preserve">
|
||||
<value>وضع صورة داخل صورة (PiP)</value>
|
||||
</data>
|
||||
<data name="Cancel" xml:space="preserve">
|
||||
<value>إلغاء</value>
|
||||
</data>
|
||||
@@ -168,24 +159,45 @@
|
||||
<data name="DockedMode" xml:space="preserve">
|
||||
<value>الوضع المثبت (Docked)</value>
|
||||
</data>
|
||||
<data name="Error" xml:space="preserve">
|
||||
<value>خطأ</value>
|
||||
</data>
|
||||
<data name="ExportSettingsSuccess" xml:space="preserve">
|
||||
<value>تم التصدير بنجاح</value>
|
||||
</data>
|
||||
<data name="FailToStartLXMusicServer" xml:space="preserve">
|
||||
<value>تعذر الاتصال بخادم موسيقى LX، يرجى الانتقال إلى الإعدادات - مصدر التشغيل - LX Music - خادم موسيقى LX للتحقق مما إذا تم إدخال الرابط بشكل صحيح</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceCleaningCache" xml:space="preserve">
|
||||
<value>تنظيف ذاكرة التخزين المؤقت...</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceConnectFailed" xml:space="preserve">
|
||||
<value>فشل الاتصال</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceConnecting" xml:space="preserve">
|
||||
<value>الاتصال جارِ...</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceFetchingFileList" xml:space="preserve">
|
||||
<value>جلب قائمة الملفات...</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceParsing" xml:space="preserve">
|
||||
<value>جاري التحليل...</value>
|
||||
</data>
|
||||
<data name="FileSystemServicePrepareToClean" xml:space="preserve">
|
||||
<value>التحضير لتنظيف ذاكرة التخزين المؤقت...</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceReady" xml:space="preserve">
|
||||
<value>جاهز</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceRootDirectoryWarning" xml:space="preserve">
|
||||
<value>تم اكتشاف مسار الدليل الجذر. قد يحتوي فهرس القرص الكامل على عدد كبير من الملفات غير الوسائط ويتسبب في استغراق الفحص وقتاً طويلاً جداً. يوصى بتحديد دليل فرعي محدد.</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceWaitingForScan" xml:space="preserve">
|
||||
<value>في انتظار المسح...</value>
|
||||
</data>
|
||||
<data name="FullscreenMode" xml:space="preserve">
|
||||
<value>وضع ملء الشاشة</value>
|
||||
</data>
|
||||
<data name="HostWindowClickThroughButton.Content" xml:space="preserve">
|
||||
<value>قفل</value>
|
||||
</data>
|
||||
<data name="HostWindowClickThroughFlyoutItem.Text" xml:space="preserve">
|
||||
<value>قفل</value>
|
||||
</data>
|
||||
<data name="HostWindowLockToolTip.Text" xml:space="preserve">
|
||||
<value>قفل</value>
|
||||
</data>
|
||||
<data name="HostWindowMusicGalleryButtonToolTip.Content" xml:space="preserve">
|
||||
<value>مكتبة الموسيقى</value>
|
||||
</data>
|
||||
@@ -204,9 +216,6 @@
|
||||
<data name="Jyutping" xml:space="preserve">
|
||||
<value>جيتبنغ (كانتونية)</value>
|
||||
</data>
|
||||
<data name="KeepAtLeastOneStatusDefault" xml:space="preserve">
|
||||
<value>يرجى التأكد من تعيين حالة واحدة على الأقل كافتراضية</value>
|
||||
</data>
|
||||
<data name="LastFMAuthFailed" xml:space="preserve">
|
||||
<value>فشل التفويض، يرجى المحاولة مرة أخرى</value>
|
||||
</data>
|
||||
@@ -270,18 +279,12 @@
|
||||
<data name="LyricsPageSettings.Text" xml:space="preserve">
|
||||
<value>الإعدادات</value>
|
||||
</data>
|
||||
<data name="LyricsPageTimelineOffsetButtonToolTip.Content" xml:space="preserve">
|
||||
<value>إزاحة الجدول الزمني للكلمات</value>
|
||||
</data>
|
||||
<data name="LyricsPageTranslationEnabled.Description" xml:space="preserve">
|
||||
<value>ستُعطى الأولوية لقراءة الترجمة داخل ملف الكلمات، وإذا لم يوجد تطابق، سيتم طلب ترجمة آلية من خادم LibreTranslate</value>
|
||||
</data>
|
||||
<data name="LyricsPageTranslationEnabled.Header" xml:space="preserve">
|
||||
<value>تفعيل الترجمة</value>
|
||||
</data>
|
||||
<data name="LyricsPageTranslationOnly.Header" xml:space="preserve">
|
||||
<value>عرض الترجمة فقط</value>
|
||||
</data>
|
||||
<data name="LyricsPageTranslationProviderPrefix.Header" xml:space="preserve">
|
||||
<value>مُزود الترجمة</value>
|
||||
</data>
|
||||
@@ -330,9 +333,6 @@
|
||||
<data name="LyricsSearchControlSongInfoMapping.Text" xml:space="preserve">
|
||||
<value>تعيين معلومات الأغنية</value>
|
||||
</data>
|
||||
<data name="LyricsSearchControlTargetSearchProvider.Header" xml:space="preserve">
|
||||
<value>مزود بحث الكلمات المستهدف</value>
|
||||
</data>
|
||||
<data name="LyricsSearchControlTitle.Header" xml:space="preserve">
|
||||
<value>العنوان</value>
|
||||
</data>
|
||||
@@ -351,15 +351,9 @@
|
||||
<data name="LyricsSearchProviderTtmlFile" xml:space="preserve">
|
||||
<value>ملف TTML محلي</value>
|
||||
</data>
|
||||
<data name="LyricsWindowImmersiveButtonToolTip.Content" xml:space="preserve">
|
||||
<value>الوضع الغامر</value>
|
||||
</data>
|
||||
<data name="LyricsWindowSettingsControlLyricsWindowConfig.Text" xml:space="preserve">
|
||||
<value>التكوين</value>
|
||||
</data>
|
||||
<data name="LyricsWindowSettingsControlLyricsWindowMode.Header" xml:space="preserve">
|
||||
<value>وضع نافذة الكلمات</value>
|
||||
</data>
|
||||
<data name="LyricsWindowSettingsControlSetDefault.Text" xml:space="preserve">
|
||||
<value>تعيين كافتراضي</value>
|
||||
</data>
|
||||
@@ -378,9 +372,6 @@
|
||||
<data name="MainPageAlbumArtOnly.Content" xml:space="preserve">
|
||||
<value>إظهار منطقة غلاف الألبوم فقط</value>
|
||||
</data>
|
||||
<data name="MainPageEnterImmersiveModeHint" xml:space="preserve">
|
||||
<value>حرك المؤشر مرة أخرى لإظهار زر التبديل</value>
|
||||
</data>
|
||||
<data name="MainPageLyriscOnly.Content" xml:space="preserve">
|
||||
<value>إظهار الكلمات فقط</value>
|
||||
</data>
|
||||
@@ -396,6 +387,18 @@
|
||||
<data name="MainPageSplitView.Content" xml:space="preserve">
|
||||
<value>عرض مقسم</value>
|
||||
</data>
|
||||
<data name="MediaSettingsControlLastSyncTime.Header" xml:space="preserve">
|
||||
<value>آخر مزامنة للوقت</value>
|
||||
</data>
|
||||
<data name="MediaSettingsControlLocalFolder" xml:space="preserve">
|
||||
<value>المجلد المحلي</value>
|
||||
</data>
|
||||
<data name="MediaSettingsControlNameSetting.Header" xml:space="preserve">
|
||||
<value>الاسم</value>
|
||||
</data>
|
||||
<data name="MediaSettingsControlSyncNow.Content" xml:space="preserve">
|
||||
<value>مزامنة الآن</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageAddToCustomList.Text" xml:space="preserve">
|
||||
<value>إضافة إلى قائمة التشغيل</value>
|
||||
</data>
|
||||
@@ -406,11 +409,17 @@
|
||||
<value>العنصر التالي</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageAddToPlayingQueue.Text" xml:space="preserve">
|
||||
<value>إضافة إلى قائمة الانتظار</value>
|
||||
<value>إضافة إلى قائمة انتظار اللعب</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageAllSongs" xml:space="preserve">
|
||||
<value>كل الموسيقى</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageDataSync.Message" xml:space="preserve">
|
||||
<value>مزامنة مكتبة الوسائط جارية...</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageDataSyncError.Message" xml:space="preserve">
|
||||
<value>توجد مشكلة في مزامنة مكتبة الوسائط</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageEmptyPlayingQueue.Text" xml:space="preserve">
|
||||
<value>مسح قائمة الانتظار</value>
|
||||
</data>
|
||||
@@ -420,9 +429,6 @@
|
||||
<data name="MusicGalleryPageFileArtist.Header" xml:space="preserve">
|
||||
<value>الفنان</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageFileInfo.Text" xml:space="preserve">
|
||||
<value>معلومات الملف</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageFileInfoBitDepth.Header" xml:space="preserve">
|
||||
<value>عمق البت (Bit Depth)</value>
|
||||
</data>
|
||||
@@ -456,15 +462,15 @@
|
||||
<data name="MusicGalleryPageFileNotFound.Text" xml:space="preserve">
|
||||
<value>لم يتم العثور على أغاني في مكتبة الوسائط</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageFolder.Text" xml:space="preserve">
|
||||
<value>المجلدات</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageImportFromFile.Text" xml:space="preserve">
|
||||
<value>استيراد من ملف</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageNewPlaylist.Text" xml:space="preserve">
|
||||
<value>إنشاء قائمة تشغيل</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPagePlayAll.Content" xml:space="preserve">
|
||||
<value>تشغيل الكل</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPagePlayingQueue.Text" xml:space="preserve">
|
||||
<value>قائمة الانتظار</value>
|
||||
</data>
|
||||
@@ -472,7 +478,7 @@
|
||||
<value>قائمة الانتظار فارغة</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPagePlaylist.Text" xml:space="preserve">
|
||||
<value>قائمة التشغيل</value>
|
||||
<value>قوائم التشغيل</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageQueueLoop.Text" xml:space="preserve">
|
||||
<value>تكرار القائمة</value>
|
||||
@@ -489,9 +495,6 @@
|
||||
<data name="MusicGalleryPageScrollToPlayingItem.Text" xml:space="preserve">
|
||||
<value>التمرير إلى العنصر المشغل</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageSelectAll.Content" xml:space="preserve">
|
||||
<value>تحديد الكل</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageSingleLoop.Text" xml:space="preserve">
|
||||
<value>تكرار أغنية واحدة</value>
|
||||
</data>
|
||||
@@ -513,20 +516,29 @@
|
||||
<data name="MusicGalleryPageSortType.Text" xml:space="preserve">
|
||||
<value>نوع الفرز</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageStarredPlaylist.Content" xml:space="preserve">
|
||||
<value>قوائم التشغيل المميزة بنجمة</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageStopTrack.Text" xml:space="preserve">
|
||||
<value>إيقاف</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageTitle" xml:space="preserve">
|
||||
<value>مكتبة الموسيقى - BetterLyrics</value>
|
||||
</data>
|
||||
<data name="MusicGalleryWindowDownButtonToolTip.Content" xml:space="preserve">
|
||||
<value>سحب</value>
|
||||
<data name="MusicSettingsControlAutoSyncInterval.Header" xml:space="preserve">
|
||||
<value>تردد المزامنة التلقائية</value>
|
||||
</data>
|
||||
<data name="MusicGalleryWindowUpButtonToolTip.Content" xml:space="preserve">
|
||||
<value>توسيع</value>
|
||||
<data name="MusicSettingsControlAutoSyncIntervalDisabled.Content" xml:space="preserve">
|
||||
<value>مطلقًا</value>
|
||||
</data>
|
||||
<data name="MusicSettingsControlAutoSyncIntervalEveryDay.Content" xml:space="preserve">
|
||||
<value>كل يوم</value>
|
||||
</data>
|
||||
<data name="MusicSettingsControlAutoSyncIntervalEveryFifteenMin.Content" xml:space="preserve">
|
||||
<value>كل 15 دقيقة</value>
|
||||
</data>
|
||||
<data name="MusicSettingsControlAutoSyncIntervalEveryHour.Content" xml:space="preserve">
|
||||
<value>كُل ساعة</value>
|
||||
</data>
|
||||
<data name="MusicSettingsControlAutoSyncIntervalEverySixHrs.Content" xml:space="preserve">
|
||||
<value>كُل 6 ساعات</value>
|
||||
</data>
|
||||
<data name="NarrowMode" xml:space="preserve">
|
||||
<value>وضع الشاشة الضيقة</value>
|
||||
@@ -543,12 +555,27 @@
|
||||
<data name="PrivacyPolicy.Content" xml:space="preserve">
|
||||
<value>سياسة الخصوصية</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlBrowse.Content" xml:space="preserve">
|
||||
<value>تصفح</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlName.Header" xml:space="preserve">
|
||||
<value>الاسم</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlName.PlaceholderText" 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="RemoteServerConfigControlPathNotExisted" xml:space="preserve">
|
||||
<value>تعذر العثور على مسار المجلد المحدد</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlPathRequired" xml:space="preserve">
|
||||
<value>المسار مطلوب</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlPort.Header" xml:space="preserve">
|
||||
<value>المنفذ</value>
|
||||
</data>
|
||||
@@ -576,9 +603,6 @@
|
||||
<data name="SetingsPageFeedback.Text" xml:space="preserve">
|
||||
<value>تعليقات</value>
|
||||
</data>
|
||||
<data name="SetingsPageInstructions.Text" xml:space="preserve">
|
||||
<value>دليل التشغيل</value>
|
||||
</data>
|
||||
<data name="SetingsPageSpecialThanks.Text" xml:space="preserve">
|
||||
<value>شكر خاص</value>
|
||||
</data>
|
||||
@@ -606,9 +630,6 @@
|
||||
<data name="SettingsPageAddFolderButton.Content" xml:space="preserve">
|
||||
<value>إضافة</value>
|
||||
</data>
|
||||
<data name="SettingsPageAdvanced.Text" xml:space="preserve">
|
||||
<value>خيارات متقدمة</value>
|
||||
</data>
|
||||
<data name="SettingsPageAlbum.Header" xml:space="preserve">
|
||||
<value>الألبوم</value>
|
||||
</data>
|
||||
@@ -624,15 +645,9 @@
|
||||
<data name="SettingsPageAlbumArtSearchProvidersConfig.Text" xml:space="preserve">
|
||||
<value>تكوين مصادر غلاف الألبوم</value>
|
||||
</data>
|
||||
<data name="SettingsPageAlbumArtSize.Header" xml:space="preserve">
|
||||
<value>حجم غلاف الألبوم</value>
|
||||
</data>
|
||||
<data name="SettingsPageAlbumEffect.Text" xml:space="preserve">
|
||||
<value>تأثير غلاف الألبوم</value>
|
||||
</data>
|
||||
<data name="SettingsPageAlbumLib.Content" xml:space="preserve">
|
||||
<value>مصدر غلاف الألبوم</value>
|
||||
</data>
|
||||
<data name="SettingsPageAlbumRadius.Header" xml:space="preserve">
|
||||
<value>نصف قطر الزاوية</value>
|
||||
</data>
|
||||
@@ -663,12 +678,6 @@
|
||||
<data name="SettingsPageAppAppearance.Text" xml:space="preserve">
|
||||
<value>مظهر التطبيق</value>
|
||||
</data>
|
||||
<data name="SettingsPageAppBehavior.Text" xml:space="preserve">
|
||||
<value>السلوك العام</value>
|
||||
</data>
|
||||
<data name="SettingsPageApply.Content" xml:space="preserve">
|
||||
<value>تطبيق</value>
|
||||
</data>
|
||||
<data name="SettingsPageArtist.Header" xml:space="preserve">
|
||||
<value>الفنان</value>
|
||||
</data>
|
||||
@@ -687,23 +696,14 @@
|
||||
<data name="SettingsPageAutoStart.Header" xml:space="preserve">
|
||||
<value>بدء التشغيل التلقائي</value>
|
||||
</data>
|
||||
<data name="SettingsPageAutoStartWindow.Header" xml:space="preserve">
|
||||
<value>عند بدء تشغيل التطبيق</value>
|
||||
</data>
|
||||
<data name="SettingsPageBackdrop.Header" xml:space="preserve">
|
||||
<value>مادة خلفية الكلمات</value>
|
||||
</data>
|
||||
<data name="SettingsPageBackgroundOverlay.Text" xml:space="preserve">
|
||||
<value>خلفية الكلمات</value>
|
||||
</data>
|
||||
<data name="SettingsPageBlurAmount.Header" xml:space="preserve">
|
||||
<value>كمية الضبابية (Blur)</value>
|
||||
</data>
|
||||
<data name="SettingsPageBorderless.Header" xml:space="preserve">
|
||||
<value>نافذة بلا حدود</value>
|
||||
</data>
|
||||
<data name="SettingsPageCache.Description" xml:space="preserve">
|
||||
<value>يشمل ملفات السجل وذاكرة التخزين المؤقت للكلمات عبر الشبكة</value>
|
||||
<value>يتضمن ملفات السجلات، وذاكرة التخزين المؤقت للكلمات عبر الإنترنت</value>
|
||||
</data>
|
||||
<data name="SettingsPageCache.Header" xml:space="preserve">
|
||||
<value>ذاكرة التخزين المؤقت (Cache)</value>
|
||||
@@ -738,9 +738,6 @@
|
||||
<data name="SettingsPageCollapseDropdown.Content" xml:space="preserve">
|
||||
<value>طي القائمة المنسدلة</value>
|
||||
</data>
|
||||
<data name="SettingsPageCompactTitleBar.Content" xml:space="preserve">
|
||||
<value>مدمج</value>
|
||||
</data>
|
||||
<data name="SettingsPageConfigName.Description" xml:space="preserve">
|
||||
<value>تسمية حالة النافذة المسجلة يمكن أن تساعدك على تمييزها بشكل أفضل</value>
|
||||
</data>
|
||||
@@ -759,18 +756,12 @@
|
||||
<data name="SettingsPageCrossfade.Content" xml:space="preserve">
|
||||
<value>تلاشي متقاطع (Crossfade)</value>
|
||||
</data>
|
||||
<data name="SettingsPageCurrentLyricsWindowStatus.Text" xml:space="preserve">
|
||||
<value>حالة نافذة الكلمات الحالية</value>
|
||||
</data>
|
||||
<data name="SettingsPageCutletDockerServer.Header" xml:space="preserve">
|
||||
<value>خدمة النقل الصوتي cutlet-docker</value>
|
||||
</data>
|
||||
<data name="SettingsPageDark.Content" xml:space="preserve">
|
||||
<value>داكن</value>
|
||||
</data>
|
||||
<data name="SettingsPageDebugOverlay.Header" xml:space="preserve">
|
||||
<value>إظهار تراكب التصحيح (Debug)</value>
|
||||
</data>
|
||||
<data name="SettingsPageDelete.Text" xml:space="preserve">
|
||||
<value>حذف</value>
|
||||
</data>
|
||||
@@ -801,15 +792,9 @@
|
||||
<data name="SettingsPageDockPlacementTop.Content" xml:space="preserve">
|
||||
<value>أعلى</value>
|
||||
</data>
|
||||
<data name="SettingsPageDockWindowHeight.Header" xml:space="preserve">
|
||||
<value>ارتفاع النافذة</value>
|
||||
</data>
|
||||
<data name="SettingsPageDragArea.Header" xml:space="preserve">
|
||||
<value>منطقة قابلة للسحب</value>
|
||||
</data>
|
||||
<data name="SettingsPageEasingFuncType.Header" xml:space="preserve">
|
||||
<value>نوع حركة التسهيل (Easing)</value>
|
||||
</data>
|
||||
<data name="SettingsPageEasingTypeEaseInOutBack.Content" xml:space="preserve">
|
||||
<value>EaseInOutBack</value>
|
||||
</data>
|
||||
@@ -873,18 +858,15 @@
|
||||
<data name="SettingsPageExitOnLyricsWindowClosed.Header" xml:space="preserve">
|
||||
<value>الخروج من البرنامج عند إغلاق نافذة الكلمات</value>
|
||||
</data>
|
||||
<data name="SettingsPageExportPlayHistoryButton.Content" xml:space="preserve">
|
||||
<value>تصدير تاريخ اللعب</value>
|
||||
</data>
|
||||
<data name="SettingsPageExportSettingsButton.Content" xml:space="preserve">
|
||||
<value>تصدير الإعدادات</value>
|
||||
</data>
|
||||
<data name="SettingsPageExtendedTitleBar.Content" xml:space="preserve">
|
||||
<value>موسع</value>
|
||||
</data>
|
||||
<data name="SettingsPageFan.Header" xml:space="preserve">
|
||||
<value>كلمات مروحية الشكل</value>
|
||||
</data>
|
||||
<data name="SettingsPageFAQ.Content" xml:space="preserve">
|
||||
<value>الأسئلة الشائعة</value>
|
||||
</data>
|
||||
<data name="SettingsPageFixedTimeStep.Header" xml:space="preserve">
|
||||
<value>تصيير بخطوة زمنية ثابتة</value>
|
||||
</data>
|
||||
@@ -921,9 +903,6 @@
|
||||
<data name="SettingsPageHeight.Header" xml:space="preserve">
|
||||
<value>الارتفاع</value>
|
||||
</data>
|
||||
<data name="SettingsPageHello.Text" xml:space="preserve">
|
||||
<value>مرحباً</value>
|
||||
</data>
|
||||
<data name="SettingsPageHelpUsTranslate.Content" xml:space="preserve">
|
||||
<value>ساعدنا في ترجمة هذا التطبيق</value>
|
||||
</data>
|
||||
@@ -954,9 +933,6 @@
|
||||
<data name="SettingsPageJapanese.Header" xml:space="preserve">
|
||||
<value>الصوتيات اليابانية</value>
|
||||
</data>
|
||||
<data name="SettingsPageJoinNowButton.Content" xml:space="preserve">
|
||||
<value>انضم الآن</value>
|
||||
</data>
|
||||
<data name="SettingsPageJyutping.Content" xml:space="preserve">
|
||||
<value>جيتبنغ (كانتونية)</value>
|
||||
</data>
|
||||
@@ -1005,18 +981,12 @@
|
||||
<data name="SettingsPageLight.Content" xml:space="preserve">
|
||||
<value>فاتح</value>
|
||||
</data>
|
||||
<data name="SettingsPageLinkedFile.Text" xml:space="preserve">
|
||||
<value>الملفات المحلية المرتبطة</value>
|
||||
</data>
|
||||
<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>التسجيل (Log)</value>
|
||||
</data>
|
||||
<data name="SettingsPageLongSyllableDuration.Header" xml:space="preserve">
|
||||
<value>عتبة المقطع الطويل</value>
|
||||
</data>
|
||||
@@ -1071,15 +1041,6 @@
|
||||
<data name="SettingsPageLyricsFgFontColor.Header" xml:space="preserve">
|
||||
<value>السطر الحالي</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsFgFontColorAdaptiveColored.Content" xml:space="preserve">
|
||||
<value>التكيف مع خلفية الكلمات (ملون)</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsFgFontColorAdaptiveGrayed.Content" xml:space="preserve">
|
||||
<value>التكيف مع خلفية الكلمات (رمادي)</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsFgFontColorCustom.Content" xml:space="preserve">
|
||||
<value>مخصص</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsFloatAnimation.Header" xml:space="preserve">
|
||||
<value>الرسوم المتحركة العائمة</value>
|
||||
</data>
|
||||
@@ -1107,27 +1068,12 @@
|
||||
<data name="SettingsPageLyricsGlowEffect.Header" xml:space="preserve">
|
||||
<value>تأثير التوهج</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsHighlight.Header" xml:space="preserve">
|
||||
<value>تمييز</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsHighlightScope.Header" xml:space="preserve">
|
||||
<value>نطاق تمييز النص الأصلي</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsLeft.Content" xml:space="preserve">
|
||||
<value>يسار</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsLight.Content" xml:space="preserve">
|
||||
<value>خفيف</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsLineFade.Header" xml:space="preserve">
|
||||
<value>تدرج حافة منطقة التشغيل</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsLineSpacingFactor.Header" xml:space="preserve">
|
||||
<value>تباعد الأسطر</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsLineSpacingFactorUnit.Text" xml:space="preserve">
|
||||
<value> مرات ارتفاع السطر</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsMedium.Content" xml:space="preserve">
|
||||
<value>متوسط</value>
|
||||
</data>
|
||||
@@ -1170,9 +1116,6 @@
|
||||
<data name="SettingsPageLyricsSemiLight.Content" xml:space="preserve">
|
||||
<value>شبه خفيف</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsShadow.Header" xml:space="preserve">
|
||||
<value>الظل</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsStyle.Text" xml:space="preserve">
|
||||
<value>نمط الكلمات</value>
|
||||
</data>
|
||||
@@ -1188,30 +1131,15 @@
|
||||
<data name="SettingsPageLyricsTimelineThreshold.Header" xml:space="preserve">
|
||||
<value>عتبة مزامنة الجدول الزمني للكلمات</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsTranslationHighlight.Header" xml:space="preserve">
|
||||
<value>تمييز الترجمة</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsTranslationSeparator.Header" xml:space="preserve">
|
||||
<value>فاصل النص الأصلي والمترجم</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsVerticalEdgeOpacity.Header" xml:space="preserve">
|
||||
<value>شفافية الحافة الرأسية</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsWindow.Text" xml:space="preserve">
|
||||
<value>نافذة الكلمات</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsWindowManager.Text" xml:space="preserve">
|
||||
<value>مدير نافذة الكلمات</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsWindowMgr.Content" xml:space="preserve">
|
||||
<value>مدير نافذة الكلمات</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsWindowSwitchHotKey.Header" xml:space="preserve">
|
||||
<value>اختصار تبديل حالة نافذة الكلمات</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsWindowToolTip.Content" xml:space="preserve">
|
||||
<value>نافذة الكلمات</value>
|
||||
</data>
|
||||
<data name="SettingsPageMatchingThreshold.Description" xml:space="preserve">
|
||||
<value>سيؤثر ضبط هذه القيمة على نتائج البحث المتسلسل والبحث بأفضل تطابق، ولكنه لن يؤثر على نتائج البحث في واجهة البحث اليدوي عن الكلمات</value>
|
||||
</data>
|
||||
@@ -1248,18 +1176,12 @@
|
||||
<data name="SettingsPageMusicLib.Header" xml:space="preserve">
|
||||
<value>مكتبة الوسائط المحلية</value>
|
||||
</data>
|
||||
<data name="SettingsPageMusicLibRealTimeWatch.Header" xml:space="preserve">
|
||||
<value>مراقبة تغييرات الملفات في الوقت الحقيقي</value>
|
||||
</data>
|
||||
<data name="SettingsPageNarrowMode.Text" xml:space="preserve">
|
||||
<value>وضع الشاشة الضيقة</value>
|
||||
</data>
|
||||
<data name="SettingsPageNextSongHotKey.Header" xml:space="preserve">
|
||||
<value>اختصار الأغنية التالية</value>
|
||||
</data>
|
||||
<data name="SettingsPageNoBackdrop.Content" xml:space="preserve">
|
||||
<value>لا شيء</value>
|
||||
</data>
|
||||
<data name="SettingsPageOctTree.Content" xml:space="preserve">
|
||||
<value>عدواني</value>
|
||||
</data>
|
||||
@@ -1287,9 +1209,6 @@
|
||||
<data name="SettingsPagePathIncludingOthersInfo" xml:space="preserve">
|
||||
<value>يحتوي هذا المجلد على مجلدات مضافة مسبقاً، يرجى حذف تلك المجلدات لإضافة هذا المجلد</value>
|
||||
</data>
|
||||
<data name="SettingsPagePathNotFound.Text" xml:space="preserve">
|
||||
<value>لا يمكن العثور على المسار في جهاز الكمبيوتر الخاص بك</value>
|
||||
</data>
|
||||
<data name="SettingsPagePatrons.Text" xml:space="preserve">
|
||||
<value>الرعاة</value>
|
||||
</data>
|
||||
@@ -1335,15 +1254,9 @@
|
||||
<data name="SettingsPageRealtimeStatus.Text" xml:space="preserve">
|
||||
<value>الحالة في الوقت الحقيقي</value>
|
||||
</data>
|
||||
<data name="SettingsPageRecord.Content" xml:space="preserve">
|
||||
<value>سجل</value>
|
||||
</data>
|
||||
<data name="SettingsPageRecordedWindowStatus.Text" xml:space="preserve">
|
||||
<value>حالة النافذة المسجلة</value>
|
||||
</data>
|
||||
<data name="SettingsPageReference.Header" xml:space="preserve">
|
||||
<value>روابط مرجعية</value>
|
||||
</data>
|
||||
<data name="SettingsPageRefreshDropdown.Content" xml:space="preserve">
|
||||
<value>تحديث القائمة المنسدلة</value>
|
||||
</data>
|
||||
@@ -1362,9 +1275,6 @@
|
||||
<data name="SettingsPageRight.Content" xml:space="preserve">
|
||||
<value>يمين</value>
|
||||
</data>
|
||||
<data name="SettingsPageRomaji.Header" xml:space="preserve">
|
||||
<value>الصوتيات اليابانية</value>
|
||||
</data>
|
||||
<data name="SettingsPageScope.Header" xml:space="preserve">
|
||||
<value>النطاق</value>
|
||||
</data>
|
||||
@@ -1404,6 +1314,9 @@
|
||||
<data name="SettingsPageSettingsManager.Header" xml:space="preserve">
|
||||
<value>مدير الإعدادات</value>
|
||||
</data>
|
||||
<data name="SettingsPageSettingsPlayHistory.Header" xml:space="preserve">
|
||||
<value>تاريخ اللعب</value>
|
||||
</data>
|
||||
<data name="SettingsPageShareHub.Content" xml:space="preserve">
|
||||
<value>تصفح مركز مشاركة الموارد عبر الإنترنت</value>
|
||||
</data>
|
||||
@@ -1431,18 +1344,12 @@
|
||||
<data name="SettingsPageShowInSwitchers.Header" xml:space="preserve">
|
||||
<value>إظهار في بيئة النظام</value>
|
||||
</data>
|
||||
<data name="SettingsPageShowLayoutDragger.Header" xml:space="preserve">
|
||||
<value>إظهار مقسم التخطيط</value>
|
||||
</data>
|
||||
<data name="SettingsPageShowTitle.Header" xml:space="preserve">
|
||||
<value>إظهار العنوان</value>
|
||||
</data>
|
||||
<data name="SettingsPageSlide.Content" xml:space="preserve">
|
||||
<value>انزلاق (Slide)</value>
|
||||
</data>
|
||||
<data name="SettingsPageSliderPrefix.Text" xml:space="preserve">
|
||||
<value>القيمة الحالية: </value>
|
||||
</data>
|
||||
<data name="SettingsPageSnowFlakeLayer.Header" xml:space="preserve">
|
||||
<value>طبقة الثلج</value>
|
||||
</data>
|
||||
@@ -1485,6 +1392,9 @@
|
||||
<data name="SettingsPageStartup.Text" xml:space="preserve">
|
||||
<value>بدء التشغيل</value>
|
||||
</data>
|
||||
<data name="SettingsPageStats.Content" xml:space="preserve">
|
||||
<value>الإحصائيات</value>
|
||||
</data>
|
||||
<data name="SettingsPageStopTrackOnGalleryWindowClosed.Header" xml:space="preserve">
|
||||
<value>إيقاف التشغيل عند إغلاق نافذة مكتبة الموسيقى</value>
|
||||
</data>
|
||||
@@ -1503,9 +1413,6 @@
|
||||
<data name="SettingsPageTaskbarPlacement.Header" xml:space="preserve">
|
||||
<value>موضع تثبيت شريط المهام</value>
|
||||
</data>
|
||||
<data name="SettingsPageThanksForPurchasing.Text" xml:space="preserve">
|
||||
<value>شكراً لشرائك BetterLyrics</value>
|
||||
</data>
|
||||
<data name="SettingsPageThanksList.Header" xml:space="preserve">
|
||||
<value>قائمة الشكر</value>
|
||||
</data>
|
||||
@@ -1524,12 +1431,6 @@
|
||||
<data name="SettingsPageTitleBarAreaWhole.Content" xml:space="preserve">
|
||||
<value>كامل النافذة</value>
|
||||
</data>
|
||||
<data name="SettingsPageTitleBarType.Header" xml:space="preserve">
|
||||
<value>حجم شريط العنوان</value>
|
||||
</data>
|
||||
<data name="SettingsPageToggleHotKey.Header" xml:space="preserve">
|
||||
<value>اختصار التبديل للداخل والخارج</value>
|
||||
</data>
|
||||
<data name="SettingsPageTranslatedText.Header" xml:space="preserve">
|
||||
<value>الترجمة</value>
|
||||
</data>
|
||||
@@ -1539,9 +1440,6 @@
|
||||
<data name="SettingsPageTranslationConfig.Header" xml:space="preserve">
|
||||
<value>خدمة ترجمة LibreTranslate</value>
|
||||
</data>
|
||||
<data name="SettingsPageTranslationInfoLink.Text" xml:space="preserve">
|
||||
<value>قم بزيارة https://github.com/LibreTranslate/LibreTranslate للحصول على دروس التثبيت والمزيد من المعلومات (هذا البرنامج ليس له أي صلة بخدمة الترجمة هذه)</value>
|
||||
</data>
|
||||
<data name="SettingsPageUserWhoPurchased.Text" xml:space="preserve">
|
||||
<value>والمستخدمين الذين اشتروا ودعموا BetterLyrics</value>
|
||||
</data>
|
||||
@@ -1554,9 +1452,6 @@
|
||||
<data name="SettingsPageWidth.Header" xml:space="preserve">
|
||||
<value>العرض</value>
|
||||
</data>
|
||||
<data name="SettingsPageWindowBounds.Header" xml:space="preserve">
|
||||
<value>حدود النافذة</value>
|
||||
</data>
|
||||
<data name="SettingsPageWorkArea.Description" xml:space="preserve">
|
||||
<value>كمساحة عمل منفصلة، يتم تثبيتها في الحافة العلوية/السفلية للشاشة</value>
|
||||
</data>
|
||||
@@ -1572,6 +1467,72 @@
|
||||
<data name="StandardMode" xml:space="preserve">
|
||||
<value>الوضع القياسي</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlActivityByHour.Text" xml:space="preserve">
|
||||
<value>النشاط بالساعة</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlCustom.Content" xml:space="preserve">
|
||||
<value>مخصص</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlEnd.Header" xml:space="preserve">
|
||||
<value>النهاية</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlLeastActive.Text" xml:space="preserve">
|
||||
<value>الأقل نشاطاً</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlMostActive.Text" xml:space="preserve">
|
||||
<value>الأكثر نشاطاً</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlRecording.Title" xml:space="preserve">
|
||||
<value>سكروبلينج...</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlSources.Text" xml:space="preserve">
|
||||
<value>المصادر</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlStart.Header" xml:space="preserve">
|
||||
<value>ابدأ</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlThisMonth.Content" xml:space="preserve">
|
||||
<value>هذا الشهر</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlThisQuarter.Content" xml:space="preserve">
|
||||
<value>هذا الربع</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlThisWeek.Content" xml:space="preserve">
|
||||
<value>هذا الأسبوع</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlThisYear.Content" xml:space="preserve">
|
||||
<value>هذا العام</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTimeRange.Header" xml:space="preserve">
|
||||
<value>النطاق الزمني</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTimes" xml:space="preserve">
|
||||
<value>التايمز</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlToday.Content" xml:space="preserve">
|
||||
<value>اليوم</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTopArtists.Text" xml:space="preserve">
|
||||
<value>كبار الفنانين</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTopSongs.Text" xml:space="preserve">
|
||||
<value>أعلى المسارات</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTopSource.Text" xml:space="preserve">
|
||||
<value>المصدر الأعلى</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTotalDuration.Text" xml:space="preserve">
|
||||
<value>المدة الإجمالية</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTrackCountAxis.AxisName" xml:space="preserve">
|
||||
<value>التايمز</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTrackCountText.Text" xml:space="preserve">
|
||||
<value>التايمز</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTracksPlayed.Text" xml:space="preserve">
|
||||
<value>المسارات التي تم تشغيلها</value>
|
||||
</data>
|
||||
<data name="SystemTrayExit.Text" xml:space="preserve">
|
||||
<value>خروج</value>
|
||||
</data>
|
||||
@@ -1581,9 +1542,6 @@
|
||||
<data name="SystemTrayMusicGallery.Text" xml:space="preserve">
|
||||
<value>فتح مكتبة الموسيقى</value>
|
||||
</data>
|
||||
<data name="SystemTrayPageTitle" xml:space="preserve">
|
||||
<value>علبة النظام - BetterLyrics</value>
|
||||
</data>
|
||||
<data name="SystemTrayResetWindowPosition.Text" xml:space="preserve">
|
||||
<value>إعادة تعيين موضع النافذة</value>
|
||||
</data>
|
||||
|
||||
@@ -129,21 +129,12 @@
|
||||
<data name="AlbumArtSearchSMTCProvider" xml:space="preserve">
|
||||
<value>Musikplayer</value>
|
||||
</data>
|
||||
<data name="AllLyricsSettingsControlPictureInPicture.Content" xml:space="preserve">
|
||||
<value>Bild-im-Bild-Modus</value>
|
||||
</data>
|
||||
<data name="AppSettingsControlGeneral.Text" xml:space="preserve">
|
||||
<value>Fenster</value>
|
||||
</data>
|
||||
<data name="ArtistsSplitHint.Text" xml:space="preserve">
|
||||
<value>Wenn Sie mehrere Künstler eingeben, trennen Sie diese bitte mit einem der folgenden Trennzeichen (nicht gemischt verwenden)</value>
|
||||
</data>
|
||||
<data name="BaseWindowHostInfoBarCheckBox.Content" xml:space="preserve">
|
||||
<value>Diese Nachricht nicht mehr anzeigen</value>
|
||||
</data>
|
||||
<data name="BaseWindowMiniFlyoutItem.Text" xml:space="preserve">
|
||||
<value>Bild-im-Bild-Modus</value>
|
||||
</data>
|
||||
<data name="Cancel" xml:space="preserve">
|
||||
<value>Abbrechen</value>
|
||||
</data>
|
||||
@@ -168,24 +159,45 @@
|
||||
<data name="DockedMode" xml:space="preserve">
|
||||
<value>Angedockter Modus</value>
|
||||
</data>
|
||||
<data name="Error" xml:space="preserve">
|
||||
<value>Fehler</value>
|
||||
</data>
|
||||
<data name="ExportSettingsSuccess" xml:space="preserve">
|
||||
<value>Export erfolgreich</value>
|
||||
</data>
|
||||
<data name="FailToStartLXMusicServer" xml:space="preserve">
|
||||
<value>Verbindung zum LX Music Server fehlgeschlagen. Bitte prüfen Sie unter Einstellungen - Wiedergabequelle - LX Music - LX Music Server, ob der Link korrekt ist</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceCleaningCache" xml:space="preserve">
|
||||
<value>Cache wird gereinigt...</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceConnectFailed" xml:space="preserve">
|
||||
<value>Verbindung fehlgeschlagen</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceConnecting" xml:space="preserve">
|
||||
<value>Verbinde...</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceFetchingFileList" xml:space="preserve">
|
||||
<value>Lade Dateiliste...</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceParsing" xml:space="preserve">
|
||||
<value>Analysiere...</value>
|
||||
</data>
|
||||
<data name="FileSystemServicePrepareToClean" xml:space="preserve">
|
||||
<value>Bereinigung des Caches vorbereiten...</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceReady" xml:space="preserve">
|
||||
<value>Bereit</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceRootDirectoryWarning" xml:space="preserve">
|
||||
<value>Der Pfad zum Stammverzeichnis wurde erkannt. Ein vollständiger Festplattenindex kann eine große Anzahl von Nicht-Mediendateien enthalten und dazu führen, dass die Suche zu lange dauert. Es wird empfohlen, ein bestimmtes Unterverzeichnis anzugeben.</value>
|
||||
</data>
|
||||
<data name="FileSystemServiceWaitingForScan" xml:space="preserve">
|
||||
<value>Auf Scans warten...</value>
|
||||
</data>
|
||||
<data name="FullscreenMode" xml:space="preserve">
|
||||
<value>Vollbildmodus</value>
|
||||
</data>
|
||||
<data name="HostWindowClickThroughButton.Content" xml:space="preserve">
|
||||
<value>Sperren</value>
|
||||
</data>
|
||||
<data name="HostWindowClickThroughFlyoutItem.Text" xml:space="preserve">
|
||||
<value>Sperren</value>
|
||||
</data>
|
||||
<data name="HostWindowLockToolTip.Text" xml:space="preserve">
|
||||
<value>Sperren</value>
|
||||
</data>
|
||||
<data name="HostWindowMusicGalleryButtonToolTip.Content" xml:space="preserve">
|
||||
<value>Musikgalerie</value>
|
||||
</data>
|
||||
@@ -204,9 +216,6 @@
|
||||
<data name="Jyutping" xml:space="preserve">
|
||||
<value>Jyutping (Kantonesisch)</value>
|
||||
</data>
|
||||
<data name="KeepAtLeastOneStatusDefault" xml:space="preserve">
|
||||
<value>Bitte stellen Sie sicher, dass mindestens ein Status als Standard festgelegt ist</value>
|
||||
</data>
|
||||
<data name="LastFMAuthFailed" xml:space="preserve">
|
||||
<value>Autorisierung fehlgeschlagen, bitte versuchen Sie es erneut</value>
|
||||
</data>
|
||||
@@ -270,18 +279,12 @@
|
||||
<data name="LyricsPageSettings.Text" xml:space="preserve">
|
||||
<value>Einstellungen</value>
|
||||
</data>
|
||||
<data name="LyricsPageTimelineOffsetButtonToolTip.Content" xml:space="preserve">
|
||||
<value>Songtext-Zeitversatz</value>
|
||||
</data>
|
||||
<data name="LyricsPageTranslationEnabled.Description" xml:space="preserve">
|
||||
<value>Priorisiere Übersetzungen innerhalb des Songtextes; falls keine Übereinstimmung gefunden wird, fordere maschinelle Übersetzung vom LibreTranslate-Server an</value>
|
||||
</data>
|
||||
<data name="LyricsPageTranslationEnabled.Header" xml:space="preserve">
|
||||
<value>Übersetzung aktivieren</value>
|
||||
</data>
|
||||
<data name="LyricsPageTranslationOnly.Header" xml:space="preserve">
|
||||
<value>Nur Übersetzung anzeigen</value>
|
||||
</data>
|
||||
<data name="LyricsPageTranslationProviderPrefix.Header" xml:space="preserve">
|
||||
<value>Übersetzungsanbieter</value>
|
||||
</data>
|
||||
@@ -330,9 +333,6 @@
|
||||
<data name="LyricsSearchControlSongInfoMapping.Text" xml:space="preserve">
|
||||
<value>Song-Info-Zuordnung</value>
|
||||
</data>
|
||||
<data name="LyricsSearchControlTargetSearchProvider.Header" xml:space="preserve">
|
||||
<value>Ziel-Suchanbieter für Songtexte</value>
|
||||
</data>
|
||||
<data name="LyricsSearchControlTitle.Header" xml:space="preserve">
|
||||
<value>Titel</value>
|
||||
</data>
|
||||
@@ -351,15 +351,9 @@
|
||||
<data name="LyricsSearchProviderTtmlFile" xml:space="preserve">
|
||||
<value>Lokale .TTML-Datei</value>
|
||||
</data>
|
||||
<data name="LyricsWindowImmersiveButtonToolTip.Content" xml:space="preserve">
|
||||
<value>Immersiver Modus</value>
|
||||
</data>
|
||||
<data name="LyricsWindowSettingsControlLyricsWindowConfig.Text" xml:space="preserve">
|
||||
<value>Konfiguration</value>
|
||||
</data>
|
||||
<data name="LyricsWindowSettingsControlLyricsWindowMode.Header" xml:space="preserve">
|
||||
<value>Songtext-Fenstermodus</value>
|
||||
</data>
|
||||
<data name="LyricsWindowSettingsControlSetDefault.Text" xml:space="preserve">
|
||||
<value>Als Standard festlegen</value>
|
||||
</data>
|
||||
@@ -378,9 +372,6 @@
|
||||
<data name="MainPageAlbumArtOnly.Content" xml:space="preserve">
|
||||
<value>Nur Albumcover-Bereich anzeigen</value>
|
||||
</data>
|
||||
<data name="MainPageEnterImmersiveModeHint" xml:space="preserve">
|
||||
<value>Erneut darüberfahren, um den Umschalt-Button anzuzeigen</value>
|
||||
</data>
|
||||
<data name="MainPageLyriscOnly.Content" xml:space="preserve">
|
||||
<value>Nur Songtext anzeigen</value>
|
||||
</data>
|
||||
@@ -396,6 +387,18 @@
|
||||
<data name="MainPageSplitView.Content" xml:space="preserve">
|
||||
<value>Geteilte Ansicht</value>
|
||||
</data>
|
||||
<data name="MediaSettingsControlLastSyncTime.Header" xml:space="preserve">
|
||||
<value>Letzte Sync-Zeit</value>
|
||||
</data>
|
||||
<data name="MediaSettingsControlLocalFolder" xml:space="preserve">
|
||||
<value>Lokaler Ordner</value>
|
||||
</data>
|
||||
<data name="MediaSettingsControlNameSetting.Header" xml:space="preserve">
|
||||
<value>Name</value>
|
||||
</data>
|
||||
<data name="MediaSettingsControlSyncNow.Content" xml:space="preserve">
|
||||
<value>Jetzt synchronisieren</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageAddToCustomList.Text" xml:space="preserve">
|
||||
<value>Zur Wiedergabeliste hinzufügen</value>
|
||||
</data>
|
||||
@@ -411,6 +414,12 @@
|
||||
<data name="MusicGalleryPageAllSongs" xml:space="preserve">
|
||||
<value>Alle Musikstücke</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageDataSync.Message" xml:space="preserve">
|
||||
<value>Synchronisierung der Medienbibliothek läuft...</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageDataSyncError.Message" xml:space="preserve">
|
||||
<value>Es gibt ein Problem mit der Synchronisierung der Medienbibliothek</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageEmptyPlayingQueue.Text" xml:space="preserve">
|
||||
<value>Warteschlange leeren</value>
|
||||
</data>
|
||||
@@ -420,9 +429,6 @@
|
||||
<data name="MusicGalleryPageFileArtist.Header" xml:space="preserve">
|
||||
<value>Künstler</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageFileInfo.Text" xml:space="preserve">
|
||||
<value>Datei-Info</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageFileInfoBitDepth.Header" xml:space="preserve">
|
||||
<value>Bittiefe</value>
|
||||
</data>
|
||||
@@ -456,15 +462,15 @@
|
||||
<data name="MusicGalleryPageFileNotFound.Text" xml:space="preserve">
|
||||
<value>Keine Songs in der Medienbibliothek gefunden</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageFolder.Text" xml:space="preserve">
|
||||
<value>Ordner</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageImportFromFile.Text" xml:space="preserve">
|
||||
<value>Aus Datei importieren</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageNewPlaylist.Text" xml:space="preserve">
|
||||
<value>Wiedergabeliste erstellen</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPagePlayAll.Content" xml:space="preserve">
|
||||
<value>Alle abspielen</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPagePlayingQueue.Text" xml:space="preserve">
|
||||
<value>Wiedergabewarteschlange</value>
|
||||
</data>
|
||||
@@ -472,7 +478,7 @@
|
||||
<value>Wiedergabewarteschlange ist leer</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPagePlaylist.Text" xml:space="preserve">
|
||||
<value>Wiedergabeliste</value>
|
||||
<value>Wiedergabelisten</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageQueueLoop.Text" xml:space="preserve">
|
||||
<value>Liste wiederholen</value>
|
||||
@@ -489,9 +495,6 @@
|
||||
<data name="MusicGalleryPageScrollToPlayingItem.Text" xml:space="preserve">
|
||||
<value>Zum aktuellen Titel scrollen</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageSelectAll.Content" xml:space="preserve">
|
||||
<value>Alle auswählen</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageSingleLoop.Text" xml:space="preserve">
|
||||
<value>Einzeltitel wiederholen</value>
|
||||
</data>
|
||||
@@ -513,20 +516,29 @@
|
||||
<data name="MusicGalleryPageSortType.Text" xml:space="preserve">
|
||||
<value>Sortiertyp</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageStarredPlaylist.Content" xml:space="preserve">
|
||||
<value>Favoriten-Wiedergabeliste</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageStopTrack.Text" xml:space="preserve">
|
||||
<value>Stopp</value>
|
||||
</data>
|
||||
<data name="MusicGalleryPageTitle" xml:space="preserve">
|
||||
<value>Musikgalerie - BetterLyrics</value>
|
||||
</data>
|
||||
<data name="MusicGalleryWindowDownButtonToolTip.Content" xml:space="preserve">
|
||||
<value>Einklappen</value>
|
||||
<data name="MusicSettingsControlAutoSyncInterval.Header" xml:space="preserve">
|
||||
<value>Auto-Sync-Frequenz</value>
|
||||
</data>
|
||||
<data name="MusicGalleryWindowUpButtonToolTip.Content" xml:space="preserve">
|
||||
<value>Ausklappen</value>
|
||||
<data name="MusicSettingsControlAutoSyncIntervalDisabled.Content" xml:space="preserve">
|
||||
<value>Niemals</value>
|
||||
</data>
|
||||
<data name="MusicSettingsControlAutoSyncIntervalEveryDay.Content" xml:space="preserve">
|
||||
<value>Täglich</value>
|
||||
</data>
|
||||
<data name="MusicSettingsControlAutoSyncIntervalEveryFifteenMin.Content" xml:space="preserve">
|
||||
<value>Alle 15 Minuten</value>
|
||||
</data>
|
||||
<data name="MusicSettingsControlAutoSyncIntervalEveryHour.Content" xml:space="preserve">
|
||||
<value>Stündlich</value>
|
||||
</data>
|
||||
<data name="MusicSettingsControlAutoSyncIntervalEverySixHrs.Content" xml:space="preserve">
|
||||
<value>Alle 6 Stunden</value>
|
||||
</data>
|
||||
<data name="NarrowMode" xml:space="preserve">
|
||||
<value>Schmaler Modus</value>
|
||||
@@ -543,12 +555,27 @@
|
||||
<data name="PrivacyPolicy.Content" xml:space="preserve">
|
||||
<value>Datenschutzrichtlinie</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlBrowse.Content" xml:space="preserve">
|
||||
<value>Durchsuchen Sie</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlName.Header" xml:space="preserve">
|
||||
<value>Name</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlName.PlaceholderText" xml:space="preserve">
|
||||
<value>Wenn Sie das Feld leer lassen, wird automatisch ein Standardname generiert.</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlPassword.Header" xml:space="preserve">
|
||||
<value>Passwort</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlPath.Header" xml:space="preserve">
|
||||
<value>Pfad</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlPathNotExisted" xml:space="preserve">
|
||||
<value>Der angegebene Ordnerpfad konnte nicht gefunden werden</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlPathRequired" xml:space="preserve">
|
||||
<value>Pfad ist erforderlich</value>
|
||||
</data>
|
||||
<data name="RemoteServerConfigControlPort.Header" xml:space="preserve">
|
||||
<value>Port</value>
|
||||
</data>
|
||||
@@ -576,9 +603,6 @@
|
||||
<data name="SetingsPageFeedback.Text" xml:space="preserve">
|
||||
<value>Feedback</value>
|
||||
</data>
|
||||
<data name="SetingsPageInstructions.Text" xml:space="preserve">
|
||||
<value>Anleitung</value>
|
||||
</data>
|
||||
<data name="SetingsPageSpecialThanks.Text" xml:space="preserve">
|
||||
<value>Besonderer Dank</value>
|
||||
</data>
|
||||
@@ -606,9 +630,6 @@
|
||||
<data name="SettingsPageAddFolderButton.Content" xml:space="preserve">
|
||||
<value>Hinzufügen</value>
|
||||
</data>
|
||||
<data name="SettingsPageAdvanced.Text" xml:space="preserve">
|
||||
<value>Erweiterte Optionen</value>
|
||||
</data>
|
||||
<data name="SettingsPageAlbum.Header" xml:space="preserve">
|
||||
<value>Album</value>
|
||||
</data>
|
||||
@@ -624,15 +645,9 @@
|
||||
<data name="SettingsPageAlbumArtSearchProvidersConfig.Text" xml:space="preserve">
|
||||
<value>Albumcover-Quellen konfigurieren</value>
|
||||
</data>
|
||||
<data name="SettingsPageAlbumArtSize.Header" xml:space="preserve">
|
||||
<value>Albumcover-Größe</value>
|
||||
</data>
|
||||
<data name="SettingsPageAlbumEffect.Text" xml:space="preserve">
|
||||
<value>Albumcover-Effekt</value>
|
||||
</data>
|
||||
<data name="SettingsPageAlbumLib.Content" xml:space="preserve">
|
||||
<value>Albumcover-Quellen</value>
|
||||
</data>
|
||||
<data name="SettingsPageAlbumRadius.Header" xml:space="preserve">
|
||||
<value>Eckenradius</value>
|
||||
</data>
|
||||
@@ -663,12 +678,6 @@
|
||||
<data name="SettingsPageAppAppearance.Text" xml:space="preserve">
|
||||
<value>Erscheinungsbild</value>
|
||||
</data>
|
||||
<data name="SettingsPageAppBehavior.Text" xml:space="preserve">
|
||||
<value>Allgemeines Verhalten</value>
|
||||
</data>
|
||||
<data name="SettingsPageApply.Content" xml:space="preserve">
|
||||
<value>Übernehmen</value>
|
||||
</data>
|
||||
<data name="SettingsPageArtist.Header" xml:space="preserve">
|
||||
<value>Künstler</value>
|
||||
</data>
|
||||
@@ -687,23 +696,14 @@
|
||||
<data name="SettingsPageAutoStart.Header" xml:space="preserve">
|
||||
<value>Autostart</value>
|
||||
</data>
|
||||
<data name="SettingsPageAutoStartWindow.Header" xml:space="preserve">
|
||||
<value>Beim Anwendungsstart</value>
|
||||
</data>
|
||||
<data name="SettingsPageBackdrop.Header" xml:space="preserve">
|
||||
<value>Material für Songtext-Hintergrund</value>
|
||||
</data>
|
||||
<data name="SettingsPageBackgroundOverlay.Text" xml:space="preserve">
|
||||
<value>Songtext-Hintergrund</value>
|
||||
</data>
|
||||
<data name="SettingsPageBlurAmount.Header" xml:space="preserve">
|
||||
<value>Unschärfe-Stärke</value>
|
||||
</data>
|
||||
<data name="SettingsPageBorderless.Header" xml:space="preserve">
|
||||
<value>Rahmenloses Fenster</value>
|
||||
</data>
|
||||
<data name="SettingsPageCache.Description" xml:space="preserve">
|
||||
<value>Einschließlich Protokolldateien, Online-Songtext-Cache</value>
|
||||
<value>Enthält Protokolldateien, Online-Liedtext-Cache</value>
|
||||
</data>
|
||||
<data name="SettingsPageCache.Header" xml:space="preserve">
|
||||
<value>Cache</value>
|
||||
@@ -738,9 +738,6 @@
|
||||
<data name="SettingsPageCollapseDropdown.Content" xml:space="preserve">
|
||||
<value>Dropdown einklappen</value>
|
||||
</data>
|
||||
<data name="SettingsPageCompactTitleBar.Content" xml:space="preserve">
|
||||
<value>Kompakt</value>
|
||||
</data>
|
||||
<data name="SettingsPageConfigName.Description" xml:space="preserve">
|
||||
<value>Das Benennen aufgezeichneter Fensterstatus hilft Ihnen, diese besser zu unterscheiden</value>
|
||||
</data>
|
||||
@@ -759,18 +756,12 @@
|
||||
<data name="SettingsPageCrossfade.Content" xml:space="preserve">
|
||||
<value>Überblenden</value>
|
||||
</data>
|
||||
<data name="SettingsPageCurrentLyricsWindowStatus.Text" xml:space="preserve">
|
||||
<value>Aktueller Status des Songtext-Fensters</value>
|
||||
</data>
|
||||
<data name="SettingsPageCutletDockerServer.Header" xml:space="preserve">
|
||||
<value>cutlet-docker Transliterationsdienst</value>
|
||||
</data>
|
||||
<data name="SettingsPageDark.Content" xml:space="preserve">
|
||||
<value>Dunkel</value>
|
||||
</data>
|
||||
<data name="SettingsPageDebugOverlay.Header" xml:space="preserve">
|
||||
<value>Debug-Overlay anzeigen</value>
|
||||
</data>
|
||||
<data name="SettingsPageDelete.Text" xml:space="preserve">
|
||||
<value>Löschen</value>
|
||||
</data>
|
||||
@@ -801,15 +792,9 @@
|
||||
<data name="SettingsPageDockPlacementTop.Content" xml:space="preserve">
|
||||
<value>Oben</value>
|
||||
</data>
|
||||
<data name="SettingsPageDockWindowHeight.Header" xml:space="preserve">
|
||||
<value>Fensterhöhe</value>
|
||||
</data>
|
||||
<data name="SettingsPageDragArea.Header" xml:space="preserve">
|
||||
<value>Ziehbereich</value>
|
||||
</data>
|
||||
<data name="SettingsPageEasingFuncType.Header" xml:space="preserve">
|
||||
<value>Easing-Funktionstyp</value>
|
||||
</data>
|
||||
<data name="SettingsPageEasingTypeEaseInOutBack.Content" xml:space="preserve">
|
||||
<value>Back Ease In-Out</value>
|
||||
</data>
|
||||
@@ -873,18 +858,15 @@
|
||||
<data name="SettingsPageExitOnLyricsWindowClosed.Header" xml:space="preserve">
|
||||
<value>App beenden, wenn Songtext-Fenster geschlossen wird</value>
|
||||
</data>
|
||||
<data name="SettingsPageExportPlayHistoryButton.Content" xml:space="preserve">
|
||||
<value>Spielverlauf exportieren</value>
|
||||
</data>
|
||||
<data name="SettingsPageExportSettingsButton.Content" xml:space="preserve">
|
||||
<value>Einstellungen exportieren</value>
|
||||
</data>
|
||||
<data name="SettingsPageExtendedTitleBar.Content" xml:space="preserve">
|
||||
<value>Erweitert</value>
|
||||
</data>
|
||||
<data name="SettingsPageFan.Header" xml:space="preserve">
|
||||
<value>Fächerförmiger Songtext</value>
|
||||
</data>
|
||||
<data name="SettingsPageFAQ.Content" xml:space="preserve">
|
||||
<value>Häufig gestellte Fragen (FAQ)</value>
|
||||
</data>
|
||||
<data name="SettingsPageFixedTimeStep.Header" xml:space="preserve">
|
||||
<value>Rendering mit festem Zeitschritt</value>
|
||||
</data>
|
||||
@@ -921,9 +903,6 @@
|
||||
<data name="SettingsPageHeight.Header" xml:space="preserve">
|
||||
<value>Höhe</value>
|
||||
</data>
|
||||
<data name="SettingsPageHello.Text" xml:space="preserve">
|
||||
<value>Hallo</value>
|
||||
</data>
|
||||
<data name="SettingsPageHelpUsTranslate.Content" xml:space="preserve">
|
||||
<value>Helfen Sie uns, diese App zu übersetzen</value>
|
||||
</data>
|
||||
@@ -954,9 +933,6 @@
|
||||
<data name="SettingsPageJapanese.Header" xml:space="preserve">
|
||||
<value>Japanische Lautschrift</value>
|
||||
</data>
|
||||
<data name="SettingsPageJoinNowButton.Content" xml:space="preserve">
|
||||
<value>Jetzt beitreten</value>
|
||||
</data>
|
||||
<data name="SettingsPageJyutping.Content" xml:space="preserve">
|
||||
<value>Jyutping (Kantonesisch)</value>
|
||||
</data>
|
||||
@@ -1005,18 +981,12 @@
|
||||
<data name="SettingsPageLight.Content" xml:space="preserve">
|
||||
<value>Hell</value>
|
||||
</data>
|
||||
<data name="SettingsPageLinkedFile.Text" xml:space="preserve">
|
||||
<value>Verknüpfte lokale Datei</value>
|
||||
</data>
|
||||
<data name="SettingsPageListenNewSession.Header" xml:space="preserve">
|
||||
<value>Auf neue Wiedergabesitzungen hören</value>
|
||||
</data>
|
||||
<data name="SettingsPageLocalFolder.Text" xml:space="preserve">
|
||||
<value>Lokaler Ordner</value>
|
||||
</data>
|
||||
<data name="SettingsPageLog.Header" xml:space="preserve">
|
||||
<value>Protokollierung</value>
|
||||
</data>
|
||||
<data name="SettingsPageLongSyllableDuration.Header" xml:space="preserve">
|
||||
<value>Schwellenwert für lange Silben</value>
|
||||
</data>
|
||||
@@ -1071,15 +1041,6 @@
|
||||
<data name="SettingsPageLyricsFgFontColor.Header" xml:space="preserve">
|
||||
<value>Aktuelle Zeile</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsFgFontColorAdaptiveColored.Content" xml:space="preserve">
|
||||
<value>An Songtext-Hintergrund anpassen (Farbig)</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsFgFontColorAdaptiveGrayed.Content" xml:space="preserve">
|
||||
<value>An Songtext-Hintergrund anpassen (Grau)</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsFgFontColorCustom.Content" xml:space="preserve">
|
||||
<value>Benutzerdefiniert</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsFloatAnimation.Header" xml:space="preserve">
|
||||
<value>Schwebe-Animation</value>
|
||||
</data>
|
||||
@@ -1107,27 +1068,12 @@
|
||||
<data name="SettingsPageLyricsGlowEffect.Header" xml:space="preserve">
|
||||
<value>Leuchteffekt</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsHighlight.Header" xml:space="preserve">
|
||||
<value>Hervorhebung</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsHighlightScope.Header" xml:space="preserve">
|
||||
<value>Hervorhebungsbereich für Originaltext</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsLeft.Content" xml:space="preserve">
|
||||
<value>Links</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsLight.Content" xml:space="preserve">
|
||||
<value>Leicht</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsLineFade.Header" xml:space="preserve">
|
||||
<value>Verblassen am Rand des Spielbereichs</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsLineSpacingFactor.Header" xml:space="preserve">
|
||||
<value>Zeilenabstand</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsLineSpacingFactorUnit.Text" xml:space="preserve">
|
||||
<value> x Zeilenhöhe</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsMedium.Content" xml:space="preserve">
|
||||
<value>Mittel</value>
|
||||
</data>
|
||||
@@ -1170,9 +1116,6 @@
|
||||
<data name="SettingsPageLyricsSemiLight.Content" xml:space="preserve">
|
||||
<value>Halb-Leicht</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsShadow.Header" xml:space="preserve">
|
||||
<value>Schatten</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsStyle.Text" xml:space="preserve">
|
||||
<value>Songtext-Stil</value>
|
||||
</data>
|
||||
@@ -1188,30 +1131,15 @@
|
||||
<data name="SettingsPageLyricsTimelineThreshold.Header" xml:space="preserve">
|
||||
<value>Schwellenwert für Songtext-Zeitachsensynchronisation</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsTranslationHighlight.Header" xml:space="preserve">
|
||||
<value>Übersetzungshervorhebung</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsTranslationSeparator.Header" xml:space="preserve">
|
||||
<value>Trennzeichen Original-Übersetzung</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsVerticalEdgeOpacity.Header" xml:space="preserve">
|
||||
<value>Deckkraft der vertikalen Kanten</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsWindow.Text" xml:space="preserve">
|
||||
<value>Songtext-Fenster</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsWindowManager.Text" xml:space="preserve">
|
||||
<value>Songtext-Fenstermanager</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsWindowMgr.Content" xml:space="preserve">
|
||||
<value>Songtext-Fenstermanager</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsWindowSwitchHotKey.Header" xml:space="preserve">
|
||||
<value>Tastenkürzel für Songtext-Fensterstatuswechsel</value>
|
||||
</data>
|
||||
<data name="SettingsPageLyricsWindowToolTip.Content" xml:space="preserve">
|
||||
<value>Songtext-Fenster</value>
|
||||
</data>
|
||||
<data name="SettingsPageMatchingThreshold.Description" xml:space="preserve">
|
||||
<value>Das Anpassen dieses Wertes beeinflusst die sequenzielle Suche und die Suche nach der besten Übereinstimmung, hat jedoch keinen Einfluss auf die Suchergebnisse in der manuellen Songtext-Suchoberfläche</value>
|
||||
</data>
|
||||
@@ -1248,18 +1176,12 @@
|
||||
<data name="SettingsPageMusicLib.Header" xml:space="preserve">
|
||||
<value>Lokale Medienbibliothek</value>
|
||||
</data>
|
||||
<data name="SettingsPageMusicLibRealTimeWatch.Header" xml:space="preserve">
|
||||
<value>Echtzeit-Dateiüberwachung</value>
|
||||
</data>
|
||||
<data name="SettingsPageNarrowMode.Text" xml:space="preserve">
|
||||
<value>Schmaler Modus</value>
|
||||
</data>
|
||||
<data name="SettingsPageNextSongHotKey.Header" xml:space="preserve">
|
||||
<value>Tastenkürzel für nächsten Titel</value>
|
||||
</data>
|
||||
<data name="SettingsPageNoBackdrop.Content" xml:space="preserve">
|
||||
<value>Keiner</value>
|
||||
</data>
|
||||
<data name="SettingsPageOctTree.Content" xml:space="preserve">
|
||||
<value>Aggressiv</value>
|
||||
</data>
|
||||
@@ -1287,9 +1209,6 @@
|
||||
<data name="SettingsPagePathIncludingOthersInfo" xml:space="preserve">
|
||||
<value>Dieser Ordner enthält bereits hinzugefügte Ordner, bitte entfernen Sie diese, um diesen Ordner hinzuzufügen</value>
|
||||
</data>
|
||||
<data name="SettingsPagePathNotFound.Text" xml:space="preserve">
|
||||
<value>Dieser Pfad konnte auf Ihrem Computer nicht gefunden werden</value>
|
||||
</data>
|
||||
<data name="SettingsPagePatrons.Text" xml:space="preserve">
|
||||
<value>Sponsoring</value>
|
||||
</data>
|
||||
@@ -1335,15 +1254,9 @@
|
||||
<data name="SettingsPageRealtimeStatus.Text" xml:space="preserve">
|
||||
<value>Echtzeit-Status</value>
|
||||
</data>
|
||||
<data name="SettingsPageRecord.Content" xml:space="preserve">
|
||||
<value>Aufzeichnen</value>
|
||||
</data>
|
||||
<data name="SettingsPageRecordedWindowStatus.Text" xml:space="preserve">
|
||||
<value>Aufgezeichneter Fensterstatus</value>
|
||||
</data>
|
||||
<data name="SettingsPageReference.Header" xml:space="preserve">
|
||||
<value>Referenzen</value>
|
||||
</data>
|
||||
<data name="SettingsPageRefreshDropdown.Content" xml:space="preserve">
|
||||
<value>Dropdown aktualisieren</value>
|
||||
</data>
|
||||
@@ -1362,9 +1275,6 @@
|
||||
<data name="SettingsPageRight.Content" xml:space="preserve">
|
||||
<value>Rechts</value>
|
||||
</data>
|
||||
<data name="SettingsPageRomaji.Header" xml:space="preserve">
|
||||
<value>Japanische Lautschrift</value>
|
||||
</data>
|
||||
<data name="SettingsPageScope.Header" xml:space="preserve">
|
||||
<value>Bereich</value>
|
||||
</data>
|
||||
@@ -1404,6 +1314,9 @@
|
||||
<data name="SettingsPageSettingsManager.Header" xml:space="preserve">
|
||||
<value>Einstellungsmanager</value>
|
||||
</data>
|
||||
<data name="SettingsPageSettingsPlayHistory.Header" xml:space="preserve">
|
||||
<value>Geschichte spielen</value>
|
||||
</data>
|
||||
<data name="SettingsPageShareHub.Content" xml:space="preserve">
|
||||
<value>Online Share Hub durchsuchen</value>
|
||||
</data>
|
||||
@@ -1431,18 +1344,12 @@
|
||||
<data name="SettingsPageShowInSwitchers.Header" xml:space="preserve">
|
||||
<value>In Systemumgebung anzeigen</value>
|
||||
</data>
|
||||
<data name="SettingsPageShowLayoutDragger.Header" xml:space="preserve">
|
||||
<value>Layout-Splitter anzeigen</value>
|
||||
</data>
|
||||
<data name="SettingsPageShowTitle.Header" xml:space="preserve">
|
||||
<value>Titel anzeigen</value>
|
||||
</data>
|
||||
<data name="SettingsPageSlide.Content" xml:space="preserve">
|
||||
<value>Gleiten</value>
|
||||
</data>
|
||||
<data name="SettingsPageSliderPrefix.Text" xml:space="preserve">
|
||||
<value>Aktueller Wert: </value>
|
||||
</data>
|
||||
<data name="SettingsPageSnowFlakeLayer.Header" xml:space="preserve">
|
||||
<value>Schneeflocken-Ebene</value>
|
||||
</data>
|
||||
@@ -1485,6 +1392,9 @@
|
||||
<data name="SettingsPageStartup.Text" xml:space="preserve">
|
||||
<value>Start</value>
|
||||
</data>
|
||||
<data name="SettingsPageStats.Content" xml:space="preserve">
|
||||
<value>Statistik</value>
|
||||
</data>
|
||||
<data name="SettingsPageStopTrackOnGalleryWindowClosed.Header" xml:space="preserve">
|
||||
<value>Wiedergabe stoppen, wenn Musikgalerie-Fenster geschlossen wird</value>
|
||||
</data>
|
||||
@@ -1503,9 +1413,6 @@
|
||||
<data name="SettingsPageTaskbarPlacement.Header" xml:space="preserve">
|
||||
<value>Taskleisten-Anheftposition</value>
|
||||
</data>
|
||||
<data name="SettingsPageThanksForPurchasing.Text" xml:space="preserve">
|
||||
<value>Vielen Dank für den Kauf von BetterLyrics</value>
|
||||
</data>
|
||||
<data name="SettingsPageThanksList.Header" xml:space="preserve">
|
||||
<value>Danksagung</value>
|
||||
</data>
|
||||
@@ -1524,12 +1431,6 @@
|
||||
<data name="SettingsPageTitleBarAreaWhole.Content" xml:space="preserve">
|
||||
<value>Ganzes Fenster</value>
|
||||
</data>
|
||||
<data name="SettingsPageTitleBarType.Header" xml:space="preserve">
|
||||
<value>Größe der Titelleiste</value>
|
||||
</data>
|
||||
<data name="SettingsPageToggleHotKey.Header" xml:space="preserve">
|
||||
<value>Tastenkürzel zum Aktivieren/Deaktivieren</value>
|
||||
</data>
|
||||
<data name="SettingsPageTranslatedText.Header" xml:space="preserve">
|
||||
<value>Übersetzung</value>
|
||||
</data>
|
||||
@@ -1539,9 +1440,6 @@
|
||||
<data name="SettingsPageTranslationConfig.Header" xml:space="preserve">
|
||||
<value>LibreTranslate-Dienst</value>
|
||||
</data>
|
||||
<data name="SettingsPageTranslationInfoLink.Text" xml:space="preserve">
|
||||
<value>Besuchen Sie https://github.com/LibreTranslate/LibreTranslate für eine Installationsanleitung und weitere Infos (Diese Software steht in keiner Verbindung zu diesem Dienst)</value>
|
||||
</data>
|
||||
<data name="SettingsPageUserWhoPurchased.Text" xml:space="preserve">
|
||||
<value>Und Benutzer, die BetterLyrics gekauft haben</value>
|
||||
</data>
|
||||
@@ -1554,9 +1452,6 @@
|
||||
<data name="SettingsPageWidth.Header" xml:space="preserve">
|
||||
<value>Breite</value>
|
||||
</data>
|
||||
<data name="SettingsPageWindowBounds.Header" xml:space="preserve">
|
||||
<value>Fenstergrenzen</value>
|
||||
</data>
|
||||
<data name="SettingsPageWorkArea.Description" xml:space="preserve">
|
||||
<value>Als separater Arbeitsbereich, angedockt an die obere/untere Kante des Bildschirms</value>
|
||||
</data>
|
||||
@@ -1572,6 +1467,72 @@
|
||||
<data name="StandardMode" xml:space="preserve">
|
||||
<value>Standard-Modus</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlActivityByHour.Text" xml:space="preserve">
|
||||
<value>Aktivität nach Stunden</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlCustom.Content" xml:space="preserve">
|
||||
<value>Benutzerdefiniert</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlEnd.Header" xml:space="preserve">
|
||||
<value>Ende</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlLeastActive.Text" xml:space="preserve">
|
||||
<value>Am wenigsten aktiv</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlMostActive.Text" xml:space="preserve">
|
||||
<value>Aktivste</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlRecording.Title" xml:space="preserve">
|
||||
<value>Scrobbling...</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlSources.Text" xml:space="preserve">
|
||||
<value>Quellen</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlStart.Header" xml:space="preserve">
|
||||
<value>Start</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlThisMonth.Content" xml:space="preserve">
|
||||
<value>Dieser Monat</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlThisQuarter.Content" xml:space="preserve">
|
||||
<value>Dieses Quartal</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlThisWeek.Content" xml:space="preserve">
|
||||
<value>Diese Woche</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlThisYear.Content" xml:space="preserve">
|
||||
<value>Dieses Jahr</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTimeRange.Header" xml:space="preserve">
|
||||
<value>Zeitspanne</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTimes" xml:space="preserve">
|
||||
<value>Zeiten</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlToday.Content" xml:space="preserve">
|
||||
<value>Heute</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTopArtists.Text" xml:space="preserve">
|
||||
<value>Top Künstler</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTopSongs.Text" xml:space="preserve">
|
||||
<value>Top Tracks</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTopSource.Text" xml:space="preserve">
|
||||
<value>Oberste Quelle</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTotalDuration.Text" xml:space="preserve">
|
||||
<value>Gesamtdauer</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTrackCountAxis.AxisName" xml:space="preserve">
|
||||
<value>Zeiten</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTrackCountText.Text" xml:space="preserve">
|
||||
<value>Zeiten</value>
|
||||
</data>
|
||||
<data name="StatsDashboardControlTracksPlayed.Text" xml:space="preserve">
|
||||
<value>Gespielte Tracks</value>
|
||||
</data>
|
||||
<data name="SystemTrayExit.Text" xml:space="preserve">
|
||||
<value>Beenden</value>
|
||||
</data>
|
||||
@@ -1581,9 +1542,6 @@
|
||||
<data name="SystemTrayMusicGallery.Text" xml:space="preserve">
|
||||
<value>Musikgalerie öffnen</value>
|
||||
</data>
|
||||
<data name="SystemTrayPageTitle" xml:space="preserve">
|
||||
<value>System-Tray - BetterLyrics</value>
|
||||
</data>
|
||||
<data name="SystemTrayResetWindowPosition.Text" xml:space="preserve">
|
||||
<value>Fensterposition zurücksetzen</value>
|
||||
</data>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user