Various updates.
This commit is contained in:
@@ -12,8 +12,6 @@ namespace Harmonia.WinUI.Caching;
|
|||||||
|
|
||||||
public class AudioBitmapImageCache(IAudioImageExtractor audioImageExtractor) : MemoryCache<Song, BitmapImage>, IAudioBitmapImageCache
|
public class AudioBitmapImageCache(IAudioImageExtractor audioImageExtractor) : MemoryCache<Song, BitmapImage>, IAudioBitmapImageCache
|
||||||
{
|
{
|
||||||
protected virtual int MaxImageWidthOrHeight => 1000;
|
|
||||||
|
|
||||||
protected override MemoryCacheOptions Options => new()
|
protected override MemoryCacheOptions Options => new()
|
||||||
{
|
{
|
||||||
SizeLimit = 200_000_000,
|
SizeLimit = 200_000_000,
|
||||||
@@ -21,8 +19,8 @@ public class AudioBitmapImageCache(IAudioImageExtractor audioImageExtractor) : M
|
|||||||
};
|
};
|
||||||
|
|
||||||
protected override TimeSpan SlidingExpiration => TimeSpan.FromSeconds(600);
|
protected override TimeSpan SlidingExpiration => TimeSpan.FromSeconds(600);
|
||||||
|
|
||||||
protected override int MaxConcurrentRequests => 8;
|
protected override int MaxConcurrentRequests => 8;
|
||||||
|
protected virtual int MaxImageWidthOrHeight => 1000;
|
||||||
|
|
||||||
protected override object? GetKey(Song key)
|
protected override object? GetKey(Song key)
|
||||||
{
|
{
|
||||||
@@ -35,7 +33,7 @@ public class AudioBitmapImageCache(IAudioImageExtractor audioImageExtractor) : M
|
|||||||
return key.ImageName;
|
return key.ImageName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return "Default";
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async ValueTask<BitmapImage?> FetchAsync(Song key, CancellationToken cancellationToken)
|
protected override async ValueTask<BitmapImage?> FetchAsync(Song key, CancellationToken cancellationToken)
|
||||||
@@ -43,7 +41,7 @@ public class AudioBitmapImageCache(IAudioImageExtractor audioImageExtractor) : M
|
|||||||
SongPictureInfo? songPictureInfo = await audioImageExtractor.ExtractImageAsync(key.FileName, cancellationToken);
|
SongPictureInfo? songPictureInfo = await audioImageExtractor.ExtractImageAsync(key.FileName, cancellationToken);
|
||||||
|
|
||||||
if (songPictureInfo == null)
|
if (songPictureInfo == null)
|
||||||
return null;
|
return GetDefaultBitmapImage();
|
||||||
|
|
||||||
using MemoryStream stream = new(songPictureInfo.Data);
|
using MemoryStream stream = new(songPictureInfo.Data);
|
||||||
|
|
||||||
@@ -57,6 +55,18 @@ public class AudioBitmapImageCache(IAudioImageExtractor audioImageExtractor) : M
|
|||||||
return bitmapImage;
|
return bitmapImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private BitmapImage GetDefaultBitmapImage()
|
||||||
|
{
|
||||||
|
Uri uri = new("ms-appx:///Assets/Default.png", UriKind.Absolute);
|
||||||
|
|
||||||
|
BitmapImage bitmapImage = new();
|
||||||
|
bitmapImage.DecodePixelWidth = GetDecodePixelWidth(bitmapImage);
|
||||||
|
bitmapImage.DecodePixelHeight = GetDecodePixelHeight(bitmapImage);
|
||||||
|
bitmapImage.UriSource = uri;
|
||||||
|
|
||||||
|
return bitmapImage;
|
||||||
|
}
|
||||||
|
|
||||||
private int GetDecodePixelWidth(BitmapImage bitmapImage)
|
private int GetDecodePixelWidth(BitmapImage bitmapImage)
|
||||||
{
|
{
|
||||||
int originalImageWidth = bitmapImage.PixelWidth;
|
int originalImageWidth = bitmapImage.PixelWidth;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.UI.Xaml.Data;
|
using Microsoft.UI.Xaml.Data;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Harmonia.WinUI.Converters;
|
namespace Harmonia.WinUI.Converters;
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ public partial class ArtistsToStringConverter : IValueConverter
|
|||||||
|
|
||||||
public object Convert(object value, Type targetType, object parameter, string language)
|
public object Convert(object value, Type targetType, object parameter, string language)
|
||||||
{
|
{
|
||||||
if (value is not string[] artists)
|
if (value is not ICollection<string> artists)
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
return string.Join(" / ", artists);
|
return string.Join(" / ", artists);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public sealed partial class NullVisibilityConverter : IValueConverter
|
|||||||
public object Convert(object value, Type targetType, object parameter, string language)
|
public object Convert(object value, Type targetType, object parameter, string language)
|
||||||
{
|
{
|
||||||
if (value is not IList list)
|
if (value is not IList list)
|
||||||
return Visibility.Collapsed;
|
return value == null ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
|
||||||
return list.Count == 0 ? Visibility.Collapsed : Visibility.Visible;
|
return list.Count == 0 ? Visibility.Collapsed : Visibility.Visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,11 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Harmonia.Core\Harmonia.Core.csproj" />
|
<ProjectReference Include="..\Harmonia.Core\Harmonia.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="Assets\Default.png">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Page Update="Resources\Geometry.xaml">
|
<Page Update="Resources\Geometry.xaml">
|
||||||
<SubType>Designer</SubType>
|
<SubType>Designer</SubType>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
<x:String x:Key="CutIcon">
|
<x:String x:Key="CutIcon">
|
||||||
M3.5 3.5c-.614-.884-.074-1.962.858-2.5L8 7.226 11.642 1c.932.538 1.472 1.616.858 2.5L8.81 8.61l1.556 2.661a2.5 2.5 0 1 1-.794.637L8 9.73l-1.572 2.177a2.5 2.5 0 1 1-.794-.637L7.19 8.61zm2.5 10a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0m7 0a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0
|
M3.5 3.5c-.614-.884-.074-1.962.858-2.5L8 7.226 11.642 1c.932.538 1.472 1.616.858 2.5L8.81 8.61l1.556 2.661a2.5 2.5 0 1 1-.794.637L8 9.73l-1.572 2.177a2.5 2.5 0 1 1-.794-.637L7.19 8.61zm2.5 10a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0m7 0a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0
|
||||||
</x:String>
|
</x:String>
|
||||||
|
|
||||||
<x:String x:Key="CopyIcon">
|
<x:String x:Key="CopyIcon">
|
||||||
M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z
|
M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z
|
||||||
</x:String>
|
</x:String>
|
||||||
|
|||||||
@@ -2,7 +2,24 @@
|
|||||||
<ResourceDictionary
|
<ResourceDictionary
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!-- Global Font -->
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="Lexend" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="TextBox" BasedOn="{StaticResource DefaultTextBoxStyle}">
|
||||||
|
<Setter Property="FontFamily" Value="Lexend" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="MenuFlyoutItem" BasedOn="{StaticResource DefaultMenuFlyoutItemStyle}">
|
||||||
|
<Setter Property="FontFamily" Value="Lexend" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="ListViewItem" BasedOn="{StaticResource DefaultListViewItemStyle}">
|
||||||
|
<Setter Property="FontFamily" Value="Lexend" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Flat Button -->
|
<!-- Flat Button -->
|
||||||
<Style x:Key="FlatButton" TargetType="Button">
|
<Style x:Key="FlatButton" TargetType="Button">
|
||||||
<Setter Property="Padding" Value="10"/>
|
<Setter Property="Padding" Value="10"/>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ using System.ComponentModel;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
|
using Windows.System;
|
||||||
|
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
|
||||||
|
|
||||||
namespace Harmonia.WinUI.ViewModels;
|
namespace Harmonia.WinUI.ViewModels;
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ public partial class PlayerViewModel : ViewModelBase
|
|||||||
private readonly IAudioPlayer _audioPlayer;
|
private readonly IAudioPlayer _audioPlayer;
|
||||||
private readonly IAudioBitmapImageCache _audioBitmapImageCache;
|
private readonly IAudioBitmapImageCache _audioBitmapImageCache;
|
||||||
private readonly DispatcherTimer _timer;
|
private readonly DispatcherTimer _timer;
|
||||||
|
private readonly DispatcherQueue _dispatcherQueue;
|
||||||
|
|
||||||
private CancellationTokenSource? _songImageCancellationTokenSource;
|
private CancellationTokenSource? _songImageCancellationTokenSource;
|
||||||
|
|
||||||
@@ -185,6 +188,18 @@ public partial class PlayerViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool CanUpdatePosition
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return _audioPlayer.State != AudioPlaybackState.Stopped;
|
||||||
|
}
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ICommand PlaySongCommand => new RelayCommand(Play);
|
public ICommand PlaySongCommand => new RelayCommand(Play);
|
||||||
public ICommand PauseSongCommand => new RelayCommand(Pause);
|
public ICommand PauseSongCommand => new RelayCommand(Pause);
|
||||||
public ICommand StopSongCommand => new RelayCommand(Stop);
|
public ICommand StopSongCommand => new RelayCommand(Stop);
|
||||||
@@ -208,6 +223,8 @@ public partial class PlayerViewModel : ViewModelBase
|
|||||||
};
|
};
|
||||||
|
|
||||||
_timer.Tick += TickTock;
|
_timer.Tick += TickTock;
|
||||||
|
|
||||||
|
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Event Handlers
|
#region Event Handlers
|
||||||
@@ -230,9 +247,13 @@ public partial class PlayerViewModel : ViewModelBase
|
|||||||
_songImageCancellationTokenSource = new();
|
_songImageCancellationTokenSource = new();
|
||||||
CancellationToken cancellationToken = _songImageCancellationTokenSource.Token;
|
CancellationToken cancellationToken = _songImageCancellationTokenSource.Token;
|
||||||
|
|
||||||
BitmapImage? bitmapImage = await _audioBitmapImageCache.GetAsync(Song, cancellationToken);
|
//BitmapImage? bitmapImage = await _audioBitmapImageCache.GetAsync(Song, cancellationToken);
|
||||||
|
|
||||||
DispatcherQueue.GetForCurrentThread().TryEnqueue(() => SongImageSource = bitmapImage);
|
_dispatcherQueue.TryEnqueue(async () =>
|
||||||
|
{
|
||||||
|
BitmapImage? bitmapImage = await _audioBitmapImageCache.GetAsync(Song, cancellationToken);
|
||||||
|
SongImageSource = bitmapImage;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAudioPlayerPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
private void OnAudioPlayerPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
@@ -241,6 +262,7 @@ public partial class PlayerViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
case nameof(_audioPlayer.State):
|
case nameof(_audioPlayer.State):
|
||||||
UpdateTimer();
|
UpdateTimer();
|
||||||
|
OnPropertyChanged(nameof(CanUpdatePosition));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using Harmonia.WinUI.Storage;
|
|||||||
using Microsoft.UI.Dispatching;
|
using Microsoft.UI.Dispatching;
|
||||||
using Microsoft.UI.Xaml.Media.Imaging;
|
using Microsoft.UI.Xaml.Media.Imaging;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
@@ -34,6 +35,7 @@ public partial class PlaylistViewModel : ViewModelBase
|
|||||||
private readonly IAudioEngine _audioEngine;
|
private readonly IAudioEngine _audioEngine;
|
||||||
private readonly IStorageProvider _storageProvider;
|
private readonly IStorageProvider _storageProvider;
|
||||||
private readonly DispatcherQueue _dispatcherQueue;
|
private readonly DispatcherQueue _dispatcherQueue;
|
||||||
|
private readonly ConcurrentDictionary<int, CancellationTokenSource> _imageCancellationTokens = [];
|
||||||
|
|
||||||
private Timer? _filterTimer;
|
private Timer? _filterTimer;
|
||||||
|
|
||||||
@@ -118,6 +120,9 @@ public partial class PlaylistViewModel : ViewModelBase
|
|||||||
public ICommand RemoveDuplicateSongsCommand => new RelayCommand(RemoveDuplicateSongs);
|
public ICommand RemoveDuplicateSongsCommand => new RelayCommand(RemoveDuplicateSongs);
|
||||||
|
|
||||||
public bool IsUserUpdating { get; set; }
|
public bool IsUserUpdating { get; set; }
|
||||||
|
private bool _isUserInitiatingSongChange;
|
||||||
|
|
||||||
|
public event EventHandler? PlayingSongChangedAutomatically;
|
||||||
|
|
||||||
public PlaylistViewModel(
|
public PlaylistViewModel(
|
||||||
IAudioPlayer audioPlayer,
|
IAudioPlayer audioPlayer,
|
||||||
@@ -216,13 +221,34 @@ public partial class PlaylistViewModel : ViewModelBase
|
|||||||
private void OnPlayingSongChanged(object? sender, EventArgs e)
|
private void OnPlayingSongChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
PlayingSong = _audioPlayer.PlayingSong;
|
PlayingSong = _audioPlayer.PlayingSong;
|
||||||
|
|
||||||
|
if (_isUserInitiatingSongChange)
|
||||||
|
{
|
||||||
|
_isUserInitiatingSongChange = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PlayingSongChangedAutomatically?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PlaySongAsync(PlaylistSong playlistSong)
|
public async Task PlaySongAsync(PlaylistSong playlistSong)
|
||||||
{
|
{
|
||||||
|
_isUserInitiatingSongChange = true;
|
||||||
await _audioPlayer.LoadAsync(playlistSong, PlaybackMode.LoadAndPlay);
|
await _audioPlayer.LoadAsync(playlistSong, PlaybackMode.LoadAndPlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<BitmapImage?> GetBitmapImageAsync(int hashCode, PlaylistSong playlistSong)
|
||||||
|
{
|
||||||
|
_imageCancellationTokens.TryGetValue(hashCode, out CancellationTokenSource? cancellationTokenSource);
|
||||||
|
cancellationTokenSource?.Cancel();
|
||||||
|
|
||||||
|
cancellationTokenSource = new();
|
||||||
|
_imageCancellationTokens.AddOrUpdate(hashCode, cancellationTokenSource, (_,_) => cancellationTokenSource);
|
||||||
|
|
||||||
|
return await _audioBitmapImageCache.GetAsync(playlistSong.Song, cancellationTokenSource.Token);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<BitmapImage?> GetBitmapAsync(PlaylistSong playlistSong, CancellationToken cancellationToken)
|
public async Task<BitmapImage?> GetBitmapAsync(PlaylistSong playlistSong, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _audioBitmapImageCache.GetAsync(playlistSong.Song, cancellationToken);
|
return await _audioBitmapImageCache.GetAsync(playlistSong.Song, cancellationToken);
|
||||||
@@ -412,6 +438,9 @@ public partial class PlaylistViewModel : ViewModelBase
|
|||||||
|
|
||||||
private bool CanPasteSongs()
|
private bool CanPasteSongs()
|
||||||
{
|
{
|
||||||
|
if (Playlist == null || SelectedPlaylistSongs.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
DataPackageView dataPackageView = Clipboard.GetContent();
|
DataPackageView dataPackageView = Clipboard.GetContent();
|
||||||
|
|
||||||
if (dataPackageView == null)
|
if (dataPackageView == null)
|
||||||
|
|||||||
@@ -11,17 +11,26 @@
|
|||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
|
||||||
<UserControl.Resources>
|
<UserControl.Resources>
|
||||||
|
<SolidColorBrush x:Key="SongItemTitleBrush" Color="#dddddd"/>
|
||||||
|
<SolidColorBrush x:Key="SongItemSubtitleBrush" Color="#aaaaaa"/>
|
||||||
|
|
||||||
<Style x:Key="PlayerGrid" TargetType="Grid">
|
<Style x:Key="PlayerGrid" TargetType="Grid">
|
||||||
<Setter Property="Background" Value="#1a1a1a"/>
|
<Setter Property="Background" Value="#1a1a1a"/>
|
||||||
<Setter Property="Padding" Value="10"/>
|
<Setter Property="Padding" Value="10"/>
|
||||||
<Setter Property="Background" Value="Transparent"/>
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style x:Key="SongTitleTextBlock" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="16"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource SongItemTitleBrush}"/>
|
||||||
|
</Style>
|
||||||
<Style x:Key="SongImage" TargetType="Image">
|
<Style x:Key="SongImage" TargetType="Image">
|
||||||
<Setter Property="Width" Value="80"/>
|
<Setter Property="Width" Value="80"/>
|
||||||
<Setter Property="Height" Value="80"/>
|
<Setter Property="Height" Value="80"/>
|
||||||
</Style>
|
</Style>
|
||||||
</UserControl.Resources>
|
</UserControl.Resources>
|
||||||
|
|
||||||
<Grid Style="{StaticResource PlayerGrid}">
|
<Grid Style="{StaticResource PlayerGrid}">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition></ColumnDefinition>
|
<ColumnDefinition></ColumnDefinition>
|
||||||
@@ -43,15 +52,16 @@
|
|||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"></RowDefinition>
|
<RowDefinition Height="Auto"></RowDefinition>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<TextBlock
|
<Border Grid.Column="0" Background="Transparent" Padding="0 8" Width="60" CornerRadius="4" VerticalAlignment="Center" Margin="0 0 6 0">
|
||||||
Grid.Column="0"
|
<TextBlock
|
||||||
Foreground="#aaaaaa"
|
Foreground="#aaaaaa"
|
||||||
Text="{Binding Position, Converter={StaticResource SecondsToString}}"
|
Text="{Binding Position, Converter={StaticResource SecondsToString}}"
|
||||||
FontSize="13"
|
FontSize="13"
|
||||||
Margin="0 0 6 0"
|
HorizontalAlignment="Right"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center">
|
||||||
LineHeight="0">
|
</TextBlock>
|
||||||
</TextBlock>
|
</Border>
|
||||||
|
|
||||||
<Slider
|
<Slider
|
||||||
Name="PositionSlider"
|
Name="PositionSlider"
|
||||||
Loaded="PositionSlider_Loaded"
|
Loaded="PositionSlider_Loaded"
|
||||||
@@ -59,19 +69,22 @@
|
|||||||
Value="{Binding CurrentPosition, Mode=TwoWay, UpdateSourceTrigger=Explicit}"
|
Value="{Binding CurrentPosition, Mode=TwoWay, UpdateSourceTrigger=Explicit}"
|
||||||
Minimum="0"
|
Minimum="0"
|
||||||
Maximum="{Binding MaxPosition, Mode=OneWay}"
|
Maximum="{Binding MaxPosition, Mode=OneWay}"
|
||||||
|
IsEnabled="{Binding CanUpdatePosition, Mode=OneWay}"
|
||||||
ThumbToolTipValueConverter="{StaticResource SecondsToString}"
|
ThumbToolTipValueConverter="{StaticResource SecondsToString}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
VerticalContentAlignment="Center" />
|
VerticalContentAlignment="Center" />
|
||||||
<TextBlock
|
|
||||||
Grid.Column="2"
|
<Border Grid.Column="2" Background="Transparent" Padding="0 8" Width="60" CornerRadius="4" VerticalAlignment="Center" Margin="6 0 0 0">
|
||||||
Foreground="#aaaaaa"
|
<TextBlock
|
||||||
Text="{Binding MaxPosition, Converter={StaticResource SecondsToString}}"
|
Foreground="#aaaaaa"
|
||||||
FontSize="13"
|
Text="{Binding MaxPosition, Converter={StaticResource SecondsToString}}"
|
||||||
Margin="6 0 0 0"
|
FontSize="13"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Song Info -->
|
<!-- Song Info -->
|
||||||
<Grid Grid.Row="1" Grid.Column="0" Name="PlayingSongGrid">
|
<Grid Grid.Row="1" Grid.Column="0" Name="PlayingSongGrid">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
@@ -80,10 +93,10 @@
|
|||||||
<Canvas Background="#19000000"></Canvas>
|
<Canvas Background="#19000000"></Canvas>
|
||||||
</Grid>
|
</Grid>
|
||||||
<StackPanel VerticalAlignment="Center">
|
<StackPanel VerticalAlignment="Center">
|
||||||
<TextBlock Foreground="#dddddd" FontWeight="SemiBold" FontSize="16" LineHeight="20" Text="{Binding Song, Converter={StaticResource SongTitle}}"></TextBlock>
|
<TextBlock Foreground="#dddddd" FontWeight="SemiBold" FontSize="16" Text="{Binding Song, Converter={StaticResource SongTitle}}"></TextBlock>
|
||||||
<TextBlock Foreground="#aaaaaa" FontSize="14" LineHeight="20" Text="{Binding Song.Artists, Converter={StaticResource ArtistsToString}}"></TextBlock>
|
<TextBlock Foreground="#aaaaaa" FontSize="14" Text="{Binding Song.Artists, Converter={StaticResource ArtistsToString}}"></TextBlock>
|
||||||
<TextBlock Foreground="#aaaaaa" FontSize="14" LineHeight="20" Text="{Binding Song.Album}"></TextBlock>
|
<TextBlock Foreground="#aaaaaa" FontSize="14" Text="{Binding Song.Album}"></TextBlock>
|
||||||
<TextBlock Foreground="#777" FontSize="12" Visibility="{Binding Song, Converter={StaticResource NullVisibility}}" FontWeight="Normal" Opacity="1.0">
|
<TextBlock Foreground="#777" FontSize="12" Visibility="{Binding Song, Converter={StaticResource NullVisibility}}">
|
||||||
<Run Text="{Binding Song.FileType}"></Run><Run Text=" - "></Run><Run Text="{Binding Song.BitRate}"></Run><Run Text=" kbps - "></Run><Run Text="{Binding Song.SampleRate}"></Run><Run Text=" Hz"></Run>
|
<Run Text="{Binding Song.FileType}"></Run><Run Text=" - "></Run><Run Text="{Binding Song.BitRate}"></Run><Run Text=" kbps - "></Run><Run Text="{Binding Song.SampleRate}"></Run><Run Text=" Hz"></Run>
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -26,14 +26,14 @@
|
|||||||
|
|
||||||
<!-- Image Border -->
|
<!-- Image Border -->
|
||||||
<Style x:Key="PlaylistSongImageBorder" TargetType="Border">
|
<Style x:Key="PlaylistSongImageBorder" TargetType="Border">
|
||||||
<Setter Property="Width" Value="75"/>
|
<Setter Property="Width" Value="60"/> <!-- as 75 -->
|
||||||
<Setter Property="Height" Value="75"/>
|
<Setter Property="Height" Value="60"/> <!-- as 75 -->
|
||||||
<Setter Property="CornerRadius" Value="8"/>
|
<Setter Property="CornerRadius" Value="8"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<DataTemplate x:Key="SongTemplate" x:DataType="playlists:PlaylistSong">
|
<DataTemplate x:Key="SongTemplate" x:DataType="playlists:PlaylistSong">
|
||||||
<!-- Background was formerly transparent -->
|
<!-- Background was formerly transparent -->
|
||||||
<Border DoubleTapped="PlaylistListViewItem_DoubleTapped" Background="Transparent">
|
<Border DoubleTapped="PlaylistListViewItem_DoubleTapped" RightTapped="PlaylistListViewItem_RightTapped" Background="Transparent">
|
||||||
<Grid Padding="10">
|
<Grid Padding="10">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="Auto"></ColumnDefinition>
|
<ColumnDefinition Width="Auto"></ColumnDefinition>
|
||||||
@@ -55,11 +55,11 @@
|
|||||||
<RowDefinition></RowDefinition>
|
<RowDefinition></RowDefinition>
|
||||||
<RowDefinition></RowDefinition>
|
<RowDefinition></RowDefinition>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<TextBlock Grid.Column="0" Grid.Row="0" Foreground="{StaticResource SongItemTitleBrush}" Text="{x:Bind Song, Converter={StaticResource SongTitle}, Mode=OneWay}" FontSize="15" FontWeight="SemiBold" LineStackingStrategy="BlockLineHeight" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" LineHeight="0">
|
<TextBlock Grid.Column="0" Grid.Row="0" Foreground="{StaticResource SongItemTitleBrush}" Text="{x:Bind Song, Converter={StaticResource SongTitle}, Mode=OneWay}" FontSize="15" FontWeight="Medium" LineStackingStrategy="BlockLineHeight" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" LineHeight="0">
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Grid.Row="1" Text="{Binding Song.Artists, Converter={StaticResource ArtistsToString}}" Foreground="{StaticResource SongItemSubtitleBrush}" TextTrimming="CharacterEllipsis" LineStackingStrategy="BlockLineHeight" LineHeight="0" FontSize="14" Margin="0 0 0 0">
|
<TextBlock Grid.Row="1" Text="{Binding Song.Artists, Converter={StaticResource ArtistsToString}}" Foreground="{StaticResource SongItemSubtitleBrush}" TextTrimming="CharacterEllipsis" LineStackingStrategy="BlockLineHeight" LineHeight="0" FontSize="13" Margin="0 0 0 0">
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Grid.Row="2" Text="{Binding Song.Album}" Foreground="{StaticResource SongItemSubtitleBrush}" TextTrimming="CharacterEllipsis" LineStackingStrategy="BlockLineHeight" LineHeight="0" FontSize="14" Margin="0 0 0 0">
|
<TextBlock Grid.Row="2" Text="{Binding Song.Album}" Foreground="{StaticResource SongItemSubtitleBrush}" TextTrimming="CharacterEllipsis" LineStackingStrategy="BlockLineHeight" LineHeight="0" FontSize="13" Margin="0 0 0 0">
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Grid.Row="0" Grid.Column="1" HorizontalAlignment="Right" VerticalAlignment="Center" TextAlignment="Right" HorizontalTextAlignment="Right" Foreground="{StaticResource SongItemSubtitleBrush}" TextTrimming="CharacterEllipsis" LineStackingStrategy="BlockLineHeight" LineHeight="0" FontSize="13" Text="{Binding Song.Length.TotalSeconds, Converter={StaticResource SecondsToString}}"></TextBlock>
|
<TextBlock Grid.Row="0" Grid.Column="1" HorizontalAlignment="Right" VerticalAlignment="Center" TextAlignment="Right" HorizontalTextAlignment="Right" Foreground="{StaticResource SongItemSubtitleBrush}" TextTrimming="CharacterEllipsis" LineStackingStrategy="BlockLineHeight" LineHeight="0" FontSize="13" Text="{Binding Song.Length.TotalSeconds, Converter={StaticResource SecondsToString}}"></TextBlock>
|
||||||
<TextBlock Grid.Row="1" Grid.Column="1" HorizontalAlignment="Right" VerticalAlignment="Center" Foreground="{StaticResource SongItemFooterBrush}" TextTrimming="CharacterEllipsis" LineStackingStrategy="BlockLineHeight" LineHeight="0" FontSize="12" Text="{Binding Song.FileType}"></TextBlock>
|
<TextBlock Grid.Row="1" Grid.Column="1" HorizontalAlignment="Right" VerticalAlignment="Center" Foreground="{StaticResource SongItemFooterBrush}" TextTrimming="CharacterEllipsis" LineStackingStrategy="BlockLineHeight" LineHeight="0" FontSize="12" Text="{Binding Song.FileType}"></TextBlock>
|
||||||
@@ -128,7 +128,66 @@
|
|||||||
Name="PlaylistListView"
|
Name="PlaylistListView"
|
||||||
ItemsSource="{Binding FilteredPlaylistSongs}"
|
ItemsSource="{Binding FilteredPlaylistSongs}"
|
||||||
ItemTemplate="{StaticResource SongTemplate}"
|
ItemTemplate="{StaticResource SongTemplate}"
|
||||||
SelectionMode="Extended">
|
SelectionMode="Extended"
|
||||||
|
SelectionChanged="PlaylistListView_SelectionChanged">
|
||||||
|
<ListView.ContextFlyout>
|
||||||
|
<MenuFlyout x:Name="PlaylistListViewMenuFlyout" Opening="MenuFlyout_Opening" ShouldConstrainToRootBounds="False" SystemBackdrop="{StaticResource AcrylicBackgroundFillColorDefaultBackdrop}">
|
||||||
|
<MenuFlyout.MenuFlyoutPresenterStyle>
|
||||||
|
<Style TargetType="MenuFlyoutPresenter">
|
||||||
|
<Setter Property="Padding" Value="10"></Setter>
|
||||||
|
<Setter Property="Background" Value="Transparent"></Setter>
|
||||||
|
</Style>
|
||||||
|
</MenuFlyout.MenuFlyoutPresenterStyle>
|
||||||
|
<MenuFlyoutItem Text="Play" FontWeight="SemiBold" Command="{Binding PlaySongCommand}">
|
||||||
|
<MenuFlyoutItem.Icon>
|
||||||
|
<PathIcon Data="{StaticResource PlayIcon}"></PathIcon>
|
||||||
|
</MenuFlyoutItem.Icon>
|
||||||
|
<MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
<KeyboardAccelerator Key="Enter"/>
|
||||||
|
</MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
</MenuFlyoutItem>
|
||||||
|
<MenuFlyoutSeparator></MenuFlyoutSeparator>
|
||||||
|
<MenuFlyoutItem Text="Remove" Command="{Binding RemoveSongsCommand}">
|
||||||
|
<MenuFlyoutItem.Icon>
|
||||||
|
<PathIcon Data="{StaticResource DeleteIcon}"></PathIcon>
|
||||||
|
</MenuFlyoutItem.Icon>
|
||||||
|
<MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
<KeyboardAccelerator Key="Delete"/>
|
||||||
|
</MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
</MenuFlyoutItem>
|
||||||
|
<MenuFlyoutSeparator></MenuFlyoutSeparator>
|
||||||
|
<MenuFlyoutItem Text="Cut" Command="{Binding CutSongsCommand}">
|
||||||
|
<MenuFlyoutItem.Icon>
|
||||||
|
<PathIcon Data="{StaticResource CutIcon}"></PathIcon>
|
||||||
|
</MenuFlyoutItem.Icon>
|
||||||
|
<MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
<KeyboardAccelerator Key="X" Modifiers="Control" />
|
||||||
|
</MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
</MenuFlyoutItem>
|
||||||
|
<MenuFlyoutItem Text="Copy" Command="{Binding CopySongsCommand}">
|
||||||
|
<MenuFlyoutItem.Icon>
|
||||||
|
<PathIcon Data="{StaticResource CopyIcon}"></PathIcon>
|
||||||
|
</MenuFlyoutItem.Icon>
|
||||||
|
<MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
<KeyboardAccelerator Key="C" Modifiers="Control" />
|
||||||
|
</MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
</MenuFlyoutItem>
|
||||||
|
<MenuFlyoutItem Text="Paste" Command="{Binding PasteSongsCommand}">
|
||||||
|
<MenuFlyoutItem.Icon>
|
||||||
|
<PathIcon Data="{StaticResource PasteIcon}"></PathIcon>
|
||||||
|
</MenuFlyoutItem.Icon>
|
||||||
|
<MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
<KeyboardAccelerator Key="V" Modifiers="Control" />
|
||||||
|
</MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
</MenuFlyoutItem>
|
||||||
|
<MenuFlyoutSeparator></MenuFlyoutSeparator>
|
||||||
|
<MenuFlyoutItem Text="Open File Location" Command="{Binding OpenFileLocationCommand}">
|
||||||
|
<MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
<KeyboardAccelerator Key="O" Modifiers="Menu" />
|
||||||
|
</MenuFlyoutItem.KeyboardAccelerators>
|
||||||
|
</MenuFlyoutItem>
|
||||||
|
</MenuFlyout>
|
||||||
|
</ListView.ContextFlyout>
|
||||||
</ListView>
|
</ListView>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
using Harmonia.Core.Playlists;
|
using CommunityToolkit.WinUI;
|
||||||
|
using Harmonia.Core.Playlists;
|
||||||
using Harmonia.WinUI.ViewModels;
|
using Harmonia.WinUI.ViewModels;
|
||||||
|
using Microsoft.UI.Dispatching;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Data;
|
||||||
using Microsoft.UI.Xaml.Input;
|
using Microsoft.UI.Xaml.Input;
|
||||||
using System.Threading.Tasks;
|
using Microsoft.UI.Xaml.Media.Imaging;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace Harmonia.WinUI.Views;
|
namespace Harmonia.WinUI.Views;
|
||||||
|
|
||||||
@@ -14,34 +19,97 @@ public sealed partial class PlaylistView : UserControl
|
|||||||
public PlaylistView()
|
public PlaylistView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
_viewModel = (PlaylistViewModel)DataContext;
|
_viewModel = (PlaylistViewModel)DataContext;
|
||||||
|
_viewModel.PlayingSongChangedAutomatically += OnPlayingSongChangedAutomatically;
|
||||||
|
|
||||||
|
foreach (MenuFlyoutItemBase item in PlaylistListViewMenuFlyout.Items)
|
||||||
|
{
|
||||||
|
item.DataContextChanged += Item_DataContextChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlayingSongChangedAutomatically(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
BringPlayingSongIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BringPlayingSongIntoView()
|
||||||
|
{
|
||||||
|
PlaylistSong? playingSong = _viewModel.PlayingSong;
|
||||||
|
|
||||||
|
if (playingSong == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ListViewItem listViewItem = (ListViewItem)PlaylistListView.ContainerFromItem(playingSong);
|
||||||
|
|
||||||
|
if (listViewItem != null)
|
||||||
|
{
|
||||||
|
listViewItem.UpdateLayout();
|
||||||
|
listViewItem.StartBringIntoView(new BringIntoViewOptions() { VerticalAlignmentRatio = .5, AnimationDesired = true });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PlaylistListView.UpdateLayout();
|
||||||
|
PlaylistListView.ScrollIntoView(playingSong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Item_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
|
||||||
|
{
|
||||||
|
if (sender is not MenuFlyoutItemBase item)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (args.NewValue == _viewModel)
|
||||||
|
return;
|
||||||
|
|
||||||
|
item.DataContext = _viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Image_Loaded(object sender, RoutedEventArgs e)
|
private void Image_Loaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
//Image? image = sender as Image;
|
if (sender is not Image image)
|
||||||
|
return;
|
||||||
|
|
||||||
//if (image == null)
|
image.DataContextChanged += Image_DataContextChanged;
|
||||||
// return;
|
|
||||||
|
|
||||||
//image.DataContextChanged += Image_DataContextChanged;
|
if (image.DataContext is not PlaylistSong playlistSong)
|
||||||
|
return;
|
||||||
|
|
||||||
//var song = (PlaylistSong)image.DataContext;
|
//Task.Run(async () => await UpdateImage(image, playlistSong));
|
||||||
|
UpdateImage(image, playlistSong);
|
||||||
//if (song == null)
|
|
||||||
// return;
|
|
||||||
|
|
||||||
//Task.Run(async () => await FetchImage(song.Song, image));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Image_Unloaded(object sender, RoutedEventArgs e)
|
private void Image_Unloaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
//Image? image = sender as Image;
|
if (sender is not Image image)
|
||||||
|
return;
|
||||||
|
|
||||||
//if (image == null)
|
image.DataContextChanged -= Image_DataContextChanged;
|
||||||
// return;
|
}
|
||||||
|
|
||||||
//image.DataContextChanged -= Image_DataContextChanged;
|
private void Image_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
|
||||||
|
{
|
||||||
|
if (sender is not Image image)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (args.NewValue is not PlaylistSong playlistSong)
|
||||||
|
return;
|
||||||
|
|
||||||
|
//Task.Run(async () => await UpdateImage(image, playlistSong));
|
||||||
|
UpdateImage(image, playlistSong);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateImage(Image image, PlaylistSong playlistSong)
|
||||||
|
{
|
||||||
|
int hashCode = image.GetHashCode();
|
||||||
|
//BitmapImage? bitmapImage = await _viewModel.GetBitmapImageAsync(hashCode, playlistSong);
|
||||||
|
|
||||||
|
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, async () =>
|
||||||
|
{
|
||||||
|
BitmapImage? bitmapImage = await _viewModel.GetBitmapImageAsync(hashCode, playlistSong);
|
||||||
|
image.Source = bitmapImage;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void PlaylistListViewItem_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
|
private async void PlaylistListViewItem_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
|
||||||
@@ -54,4 +122,45 @@ public sealed partial class PlaylistView : UserControl
|
|||||||
|
|
||||||
await _viewModel.PlaySongAsync(playlistSong);
|
await _viewModel.PlaySongAsync(playlistSong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void PlaylistListViewItem_RightTapped(object sender, RightTappedRoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not FrameworkElement element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (element == null || element.DataContext is not PlaylistSong playlistSong)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (PlaylistListView.SelectedItems.Contains(playlistSong))
|
||||||
|
return;
|
||||||
|
|
||||||
|
int index = PlaylistListView.Items.IndexOf(playlistSong);
|
||||||
|
|
||||||
|
PlaylistListView.DeselectAll();
|
||||||
|
PlaylistListView.SelectRange(new ItemIndexRange(index, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PlaylistListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not ListView listView)
|
||||||
|
return;
|
||||||
|
|
||||||
|
PlaylistSong[] selectedPlaylistSongs = [.. listView.SelectedItems.Cast<PlaylistSong>()];
|
||||||
|
|
||||||
|
_viewModel.SelectedPlaylistSongs = [.. selectedPlaylistSongs];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MenuFlyout_Opening(object sender, object e)
|
||||||
|
{
|
||||||
|
if (sender is not MenuFlyout menuFlyout)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (MenuFlyoutItemBase item in menuFlyout.Items)
|
||||||
|
{
|
||||||
|
if (item.DataContext != _viewModel)
|
||||||
|
{
|
||||||
|
item.DataContext = _viewModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user