Added playlist view and initial playlist view logic.

This commit is contained in:
2025-03-10 09:50:05 -04:00
parent 9890236710
commit bd9b30abbd
19 changed files with 619 additions and 20 deletions

View File

@@ -20,6 +20,7 @@ public class AudioPlayer : IAudioPlayer
{ {
_playlist = value; _playlist = value;
NotifyPropertyChanged(nameof(Playlist)); NotifyPropertyChanged(nameof(Playlist));
PlaylistChanged?.Invoke(this, new());
} }
} }
@@ -112,6 +113,7 @@ public class AudioPlayer : IAudioPlayer
protected virtual int PreviousSongSecondsThreshold => 5; protected virtual int PreviousSongSecondsThreshold => 5;
public event EventHandler? PlaylistChanged;
public event EventHandler? PlayingSongChanged; public event EventHandler? PlayingSongChanged;
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;

View File

@@ -24,6 +24,7 @@ public interface IAudioPlayer
Task PreviousAsync(); Task PreviousAsync();
Task NextAsync(); Task NextAsync();
event EventHandler PlaylistChanged;
event EventHandler PlayingSongChanged; event EventHandler PlayingSongChanged;
event PropertyChangedEventHandler PropertyChanged; event PropertyChangedEventHandler PropertyChanged;
} }

View File

@@ -23,7 +23,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="xunit.v3" Version="1.1.0" /> <PackageReference Include="xunit.v3" Version="2.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Harmonia.UI.ViewModels" xmlns:vm="clr-namespace:Harmonia.UI.ViewModels"
xmlns:control="clr-namespace:Harmonia.UI.Controls" xmlns:control="clr-namespace:Harmonia.UI.Controls"
xmlns:converters="clr-namespace:Harmonia.UI.Converters"
xmlns:semi="https://irihi.tech/semi" xmlns:semi="https://irihi.tech/semi"
x:Class="Harmonia.UI.App" x:Class="Harmonia.UI.App"
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
@@ -16,6 +17,51 @@
<ResourceDictionary> <ResourceDictionary>
<vm:ViewModelLocator x:Key="Locator" /> <vm:ViewModelLocator x:Key="Locator" />
</ResourceDictionary> </ResourceDictionary>
<!-- Converters -->
<converters:SecondsToStringConverter x:Key="SecondsToString" />
<converters:ArtistsToStringConverter x:Key="ArtistsToString" />
<converters:NullVisibilityConverter x:Key="NullVisibility" />
<converters:SongTitleConverter x:Key="SongTitle" />
<converters:VolumeStateToIconConverter x:Key="VolumeStateToIconConverter" />
<converters:RepeatStateToIconConverter x:Key="RepeatStateToIconConverter" />
<converters:PlaylistSongEqualityConverter x:Key="PlaylistSongEquality" />
<!-- Geometry -->
<StreamGeometry x:Key="VolumeMutedIcon">
M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06
M13.854 5.646a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0
</StreamGeometry>
<StreamGeometry x:Key="VolumeOffIcon">
M8.707 11.182A4.5 4.5 0 0 0 10.025 8a4.5 4.5 0 0 0-1.318-3.182L8 5.525A3.5 3.5 0 0 1 9.025 8 3.5 3.5 0 0 1 8 10.475z
M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06
</StreamGeometry>
<StreamGeometry x:Key="VolumeLowIcon">
M8.707 11.182A4.5 4.5 0 0 0 10.025 8a4.5 4.5 0 0 0-1.318-3.182L8 5.525A3.5 3.5 0 0 1 9.025 8 3.5 3.5 0 0 1 8 10.475z
M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06
</StreamGeometry>
<StreamGeometry x:Key="VolumeMediumIcon">
M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.48 5.48 0 0 1 11.025 8a5.48 5.48 0 0 1-1.61 3.89z
M8.707 11.182A4.5 4.5 0 0 0 10.025 8a4.5 4.5 0 0 0-1.318-3.182L8 5.525A3.5 3.5 0 0 1 9.025 8 3.5 3.5 0 0 1 8 10.475z
M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06
</StreamGeometry>
<StreamGeometry x:Key="VolumeHighIcon">
M11.536 14.01A8.47 8.47 0 0 0 14.026 8a8.47 8.47 0 0 0-2.49-6.01l-.708.707A7.48 7.48 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303z
M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.48 5.48 0 0 1 11.025 8a5.48 5.48 0 0 1-1.61 3.89z
M8.707 11.182A4.5 4.5 0 0 0 10.025 8a4.5 4.5 0 0 0-1.318-3.182L8 5.525A3.5 3.5 0 0 1 9.025 8 3.5 3.5 0 0 1 8 10.475z
M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06
</StreamGeometry>
<StreamGeometry x:Key="ShuffleIcon">
M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.6 9.6 0 0 0 7.556 8a9.6 9.6 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.6 10.6 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.6 9.6 0 0 0 6.444 8a9.6 9.6 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5
M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192
M13 14.466v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192
</StreamGeometry>
<StreamGeometry x:Key="RepeatIcon">
M11 5.466V4H5a4 4 0 0 0-3.584 5.777.5.5 0 1 1-.896.446A5 5 0 0 1 5 3h6V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192m3.81.086a.5.5 0 0 1 .67.225A5 5 0 0 1 11 13H5v1.466a.25.25 0 0 1-.41.192l-2.36-1.966a.25.25 0 0 1 0-.384l2.36-1.966a.25.25 0 0 1 .41.192V12h6a4 4 0 0 0 3.585-5.777.5.5 0 0 1 .225-.67Z
</StreamGeometry>
<StreamGeometry x:Key="RepeatOneIcon">
M11 4v1.466a.25.25 0 0 0 .41.192l2.36-1.966a.25.25 0 0 0 0-.384l-2.36-1.966a.25.25 0 0 0-.41.192V3H5a5 5 0 0 0-4.48 7.223.5.5 0 0 0 .896-.446A4 4 0 0 1 5 4zm4.48 1.777a.5.5 0 0 0-.896.446A4 4 0 0 1 11 12H5.001v-1.466a.25.25 0 0 0-.41-.192l-2.36 1.966a.25.25 0 0 0 0 .384l2.36 1.966a.25.25 0 0 0 .41-.192V13h6a5 5 0 0 0 4.48-7.223Z
M9 5.5a.5.5 0 0 0-.854-.354l-1.75 1.75a.5.5 0 1 0 .708.708L8 6.707V10.5a.5.5 0 0 0 1 0z
</StreamGeometry>
</Application.Resources> </Application.Resources>
</Application> </Application>

View File

@@ -3,6 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins; using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Harmonia.Core.Extensions; using Harmonia.Core.Extensions;
using Harmonia.UI.Caching;
using Harmonia.UI.ViewModels; using Harmonia.UI.ViewModels;
using Harmonia.UI.Views; using Harmonia.UI.Views;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -22,6 +23,9 @@ public partial class App : Application
services.AddSingleton<MainWindow>(); services.AddSingleton<MainWindow>();
services.AddSingleton<PlaybackBarViewModel>(); services.AddSingleton<PlaybackBarViewModel>();
services.AddSingleton<PlayingSongInfoViewModel>(); services.AddSingleton<PlayingSongInfoViewModel>();
services.AddSingleton<PlaylistViewModel>();
services.AddSingleton<IAudioBitmapCache, AudioBitmapCache>();
services.AddHarmonia(); services.AddHarmonia();

View File

@@ -0,0 +1,55 @@
using Avalonia.Media.Imaging;
using Harmonia.Core.Caching;
using Harmonia.Core.Imaging;
using Harmonia.Core.Models;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Harmonia.UI.Caching;
public class AudioBitmapCache(IAudioImageExtractor audioImageExtractor) : MemoryCache<Song, Bitmap>, IAudioBitmapCache
{
protected override MemoryCacheOptions Options => new()
{
SizeLimit = 40,
CompactionPercentage = 0.2,
};
protected override TimeSpan SlidingExpiration => TimeSpan.FromSeconds(600);
protected override int MaxConcurrentRequests => 8;
protected override object? GetKey(Song key)
{
if (string.IsNullOrWhiteSpace(key.ImageHash) == false)
{
return key.ImageHash;
}
else if (string.IsNullOrWhiteSpace(key.ImageName) == false)
{
return key.ImageName;
}
return null;
}
protected override async ValueTask<Bitmap?> FetchAsync(Song key, CancellationToken cancellationToken)
{
SongPictureInfo? songPictureInfo = await audioImageExtractor.ExtractImageAsync(key.FileName, cancellationToken);
if (songPictureInfo == null)
return null;
using MemoryStream stream = new(songPictureInfo.Data);
return new Bitmap(stream);
}
protected override long GetEntrySize(Bitmap entry)
{
return 1;
}
}

View File

@@ -0,0 +1,10 @@
using Avalonia.Media.Imaging;
using Harmonia.Core.Caching;
using Harmonia.Core.Models;
namespace Harmonia.UI.Caching;
public interface IAudioBitmapCache : ICache<Song, Bitmap>
{
}

View File

@@ -0,0 +1,28 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data.Converters;
using Harmonia.Core.Player;
using System;
using System.Globalization;
namespace Harmonia.UI.Converters;
public class RepeatStateToIconConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not RepeatState repeatMode)
return null;
string resourceName = repeatMode == RepeatState.RepeatOne
? "RepeatOneIcon"
: "RepeatIcon";
return Application.Current?.FindResource(resourceName);
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,37 @@
using Avalonia.Controls;
using Avalonia.Data.Converters;
using Avalonia;
using Harmonia.UI.Models;
using System;
using System.Globalization;
using Harmonia.Core.Playlists;
namespace Harmonia.UI.Converters;
public class VolumeStateToIconConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not VolumeState volumeState)
return AvaloniaProperty.UnsetValue;
return Application.Current?.FindResource($"Volume{volumeState}Icon") ?? AvaloniaProperty.UnsetValue;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException();
}
public class PlaylistSongEqualityConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is PlaylistSong playingSong && parameter is PlaylistSong currentSong)
{
return playingSong == currentSong;
}
return false;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotImplementedException();
}

View File

@@ -27,6 +27,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Update="Views\PlaylistView.axaml.cs">
<DependentUpon>PlaylistView.axaml</DependentUpon>
</Compile>
<Compile Update="Views\PlayingSongInfo.axaml.cs"> <Compile Update="Views\PlayingSongInfo.axaml.cs">
<DependentUpon>PlayingSongInfo.axaml</DependentUpon> <DependentUpon>PlayingSongInfo.axaml</DependentUpon>
</Compile> </Compile>

View File

@@ -0,0 +1,10 @@
namespace Harmonia.UI.Models;
public enum VolumeState
{
Muted,
Off,
Low,
Medium,
High
}

View File

@@ -7,6 +7,7 @@ using Harmonia.Core.Models;
using Harmonia.Core.Player; using Harmonia.Core.Player;
using Harmonia.Core.Playlists; using Harmonia.Core.Playlists;
using Harmonia.Core.Scanner; using Harmonia.Core.Scanner;
using Harmonia.UI.Models;
using System; using System;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@@ -118,6 +119,57 @@ public partial class PlaybackBarViewModel : ViewModelBase, IDisposable
} }
} }
public double Volume
{
get
{
return _audioPlayer.Volume;
}
set
{
if (IsMuted)
IsMuted = false;
_audioPlayer.Volume = value;
OnPropertyChanged();
OnPropertyChanged(nameof(VolumeState));
}
}
public bool IsMuted
{
get
{
return _audioPlayer.IsMuted;
}
set
{
_audioPlayer.IsMuted = value;
OnPropertyChanged();
OnPropertyChanged(nameof(VolumeState));
}
}
public VolumeState VolumeState
{
get
{
if (IsMuted)
return VolumeState.Muted;
if (Volume == 0)
return VolumeState.Off;
if (Volume < .33)
return VolumeState.Low;
if (Volume < .66)
return VolumeState.Medium;
return VolumeState.High;
}
}
public bool IsRandom public bool IsRandom
{ {
get get
@@ -131,11 +183,27 @@ public partial class PlaybackBarViewModel : ViewModelBase, IDisposable
} }
} }
public RepeatState RepeatState
{
get
{
return _audioPlayer.RepeatState;
}
private set
{
_audioPlayer.RepeatState = value;
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);
public ICommand PreviousSongCommand => new RelayCommand(Previous); public ICommand PreviousSongCommand => new RelayCommand(Previous);
public ICommand NextSongCommand => new RelayCommand(Next); public ICommand NextSongCommand => new RelayCommand(Next);
public ICommand ToggleMuteCommand => new RelayCommand(ToggleMute);
public ICommand ToggleRandomizerCommand => new RelayCommand(ToggleRandomizer);
public ICommand ToggleRepeatCommand => new RelayCommand(ToggleRepeat);
public PlaybackBarViewModel(IAudioPlayer audioPlayer, IAudioImageCache audioImageCache, IPlaylistRepository playlistRepository, IAudioFileScanner audioFileScanner) public PlaybackBarViewModel(IAudioPlayer audioPlayer, IAudioImageCache audioImageCache, IPlaylistRepository playlistRepository, IAudioFileScanner audioFileScanner)
{ {
@@ -219,6 +287,32 @@ public partial class PlaybackBarViewModel : ViewModelBase, IDisposable
_audioPlayer.NextAsync(); _audioPlayer.NextAsync();
} }
public void ToggleMute()
{
IsMuted = !IsMuted;
}
public void ToggleRandomizer()
{
IsRandom = !IsRandom;
}
public void ToggleRepeat()
{
RepeatState = GetNextRepeatState();
}
private RepeatState GetNextRepeatState()
{
return _audioPlayer.RepeatState switch
{
RepeatState.Off => RepeatState.RepeatAll,
RepeatState.RepeatAll => RepeatState.RepeatOne,
RepeatState.RepeatOne => RepeatState.Off,
_ => _audioPlayer.RepeatState,
};
}
public void Dispose() public void Dispose()
{ {
SongImageSource?.Dispose(); SongImageSource?.Dispose();

View File

@@ -0,0 +1,65 @@
using Harmonia.Core.Player;
using Harmonia.Core.Playlists;
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using System.Threading;
using Avalonia.Media.Imaging;
using System.Collections.Concurrent;
using Harmonia.UI.Caching;
namespace Harmonia.UI.ViewModels;
public class PlaylistViewModel : ViewModelBase
{
private readonly IAudioPlayer _audioPlayer;
private readonly IAudioBitmapCache _audioBitmapImageCache;
private readonly ConcurrentDictionary<string, Bitmap> _bitmapDictionary = [];
public PlaylistSong? PlayingSong => _audioPlayer.PlayingSong;
private ObservableCollection<PlaylistSong> _playlistSongs = [];
public ObservableCollection<PlaylistSong> PlaylistSongs
{
get
{
return _playlistSongs;
}
set
{
_playlistSongs = value;
OnPropertyChanged();
}
}
public PlaylistViewModel(IAudioPlayer audioPlayer, IAudioBitmapCache audioBitmapImageCache)
{
_audioPlayer = audioPlayer;
_audioPlayer.PlaylistChanged += OnAudioPlayerPlaylistChanged;
_audioPlayer.PlayingSongChanged += OnAudioPlayerPlayingSongChanged;
_audioBitmapImageCache = audioBitmapImageCache;
}
private void OnAudioPlayerPlaylistChanged(object? sender, EventArgs e)
{
PlaylistSong[] playlistSongs = _audioPlayer.Playlist?.Songs.ToArray() ?? [];
PlaylistSongs = [.. playlistSongs];
}
private void OnAudioPlayerPlayingSongChanged(object? sender, EventArgs e)
{
OnPropertyChanged(nameof(PlayingSong));
}
public void PlaySong(PlaylistSong playlistSong)
{
_audioPlayer.LoadAsync(playlistSong, PlaybackMode.LoadAndPlay);
}
public async Task<Bitmap?> GetBitmapAsync(PlaylistSong playlistSong, CancellationToken cancellationToken)
{
return await _audioBitmapImageCache.GetAsync(playlistSong.Song, cancellationToken);
}
}

View File

@@ -12,4 +12,7 @@ public class ViewModelLocator
public static PlayingSongInfoViewModel PlayingSongInfoViewModel public static PlayingSongInfoViewModel PlayingSongInfoViewModel
=> App.ServiceProvider.GetRequiredService<PlayingSongInfoViewModel>(); => App.ServiceProvider.GetRequiredService<PlayingSongInfoViewModel>();
public static PlaylistViewModel PlaylistViewModel
=> App.ServiceProvider.GetRequiredService<PlaylistViewModel>();
} }

View File

@@ -23,6 +23,12 @@
<Setter Property="BorderThickness" Value="0"/> <Setter Property="BorderThickness" Value="0"/>
</Style> </Style>
<!-- Flat Button ViewBox -->
<!--<Style Selector="Viewbox.FlatButtonViewbox">
<Setter Property="Width" Value="18"/>
<Setter Property="Height" Value="18"/>
</Style>-->
<!-- Flat Button Path Icon --> <!-- Flat Button Path Icon -->
<Style Selector="PathIcon.FlatButtonIcon"> <Style Selector="PathIcon.FlatButtonIcon">
<Setter Property="Padding" Value="10"/> <Setter Property="Padding" Value="10"/>
@@ -31,6 +37,12 @@
<Setter Property="Foreground" Value="White"/> <Setter Property="Foreground" Value="White"/>
</Style> </Style>
<!-- Flat Button Path Icon (Large) -->
<Style Selector="PathIcon.FlatButtonIcon.Medium">
<Setter Property="Width" Value="24"/>
<Setter Property="Height" Value="24"/>
</Style>
<!-- Flat Button Path Icon (Large) --> <!-- Flat Button Path Icon (Large) -->
<Style Selector="PathIcon.FlatButtonIcon.Large"> <Style Selector="PathIcon.FlatButtonIcon.Large">
<Setter Property="Width" Value="36"/> <Setter Property="Width" Value="36"/>
@@ -106,14 +118,36 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<Grid Grid.Row="1" Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right"> <Grid Grid.Row="1" Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Button Classes="Flat" Command="{Binding ToggleMuteCommand}">
<Grid>
<!--<PathIcon Classes="FlatButtonIcon Medium" Data="{StaticResource VolumeHighIcon}"></PathIcon>-->
<PathIcon Classes="FlatButtonIcon Medium" Data="{Binding VolumeState, Converter={StaticResource VolumeStateToIconConverter}}"></PathIcon>
</Grid>
</Button>
<Slider Value="{Binding Volume, Mode=TwoWay}" Minimum="0" Maximum="1" PointerWheelChanged="VolumeSlider_PointerWheelChanged" Width="200" VerticalAlignment="Center" />
<Button Classes="Flat" Command="{Binding ToggleRandomizerCommand}">
<PathIcon Classes="FlatButtonIcon Medium" Classes.ShuffleOn="{Binding IsRandom}" Data="{StaticResource ShuffleIcon}">
<PathIcon.Styles>
<Style Selector="PathIcon.ShuffleOn">
<Setter Property="Foreground" Value="#76b9ed"/>
</Style>
</PathIcon.Styles>
</PathIcon>
</Button>
<Button Classes="Flat" Command="{Binding ToggleRepeatCommand}">
<PathIcon Classes="FlatButtonIcon Medium" Data="{Binding RepeatState, Converter={StaticResource RepeatStateToIconConverter}}"></PathIcon>
</Button>
<Button Classes="Flat"> <Button Classes="Flat">
<PathIcon Classes="FlatButtonIcon" Data="{StaticResource SemiIconSetting}"></PathIcon> <PathIcon Classes="FlatButtonIcon" Data="{StaticResource SemiIconSetting}"></PathIcon>
<Button.Flyout> <Button.Flyout>
<MenuFlyout Placement="Top"> <Flyout Placement="Top">
<RadioButton Content="Compact" VerticalContentAlignment="Stretch" HorizontalAlignment="Stretch" GroupName="Density" /> <StackPanel>
<RadioButton Content="Comfortable" VerticalContentAlignment="Stretch" HorizontalAlignment="Stretch" GroupName="Density" /> <TextBlock Text="Density"></TextBlock>
<RadioButton Content="Cozy" VerticalContentAlignment="Stretch" HorizontalAlignment="Stretch" GroupName="Density" /> <RadioButton Content="Compact" Margin="10" HorizontalAlignment="Stretch" GroupName="Density" />
</MenuFlyout> <RadioButton Content="Comfortable" Margin="10" HorizontalAlignment="Stretch" GroupName="Density" />
<RadioButton Content="Cozy" Margin="10" HorizontalAlignment="Stretch" GroupName="Density" />
</StackPanel>
</Flyout>
</Button.Flyout> </Button.Flyout>
</Button> </Button>
</StackPanel> </StackPanel>

View File

@@ -45,4 +45,16 @@ public partial class PlaybackBar : UserControl
_viewModel.CurrentPosition = slider.Value; _viewModel.CurrentPosition = slider.Value;
_viewModel.IsPositionChangeInProgress = false; _viewModel.IsPositionChangeInProgress = false;
} }
private void VolumeSlider_PointerWheelChanged(object? sender, PointerWheelEventArgs e)
{
double mouseWheelDelta = e.Delta.Y;
if (mouseWheelDelta == 0)
return;
double delta = mouseWheelDelta > 0 ? .02 : -.02;
_viewModel.Volume += delta;
}
} }

View File

@@ -3,25 +3,36 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Harmonia.UI.ViewModels" xmlns:vm="clr-namespace:Harmonia.UI.ViewModels"
xmlns:views="clr-namespace:Harmonia.UI.Views"
xmlns:converter="clr-namespace:Harmonia.UI.Converters" xmlns:converter="clr-namespace:Harmonia.UI.Converters"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
DataContext="{x:Static vm:ViewModelLocator.PlayingSongInfoViewModel}" DataContext="{x:Static vm:ViewModelLocator.PlayingSongInfoViewModel}"
x:Class="Harmonia.UI.Views.PlayingSongInfo" x:Class="Harmonia.UI.Views.PlayingSongInfo"
x:DataType="vm:PlayingSongInfoViewModel"> x:DataType="vm:PlayingSongInfoViewModel">
<Grid> <Grid>
<Image Source="{Binding SongImageSource, Mode=OneWay}" Stretch="UniformToFill" HorizontalAlignment="Center" VerticalAlignment="Center"> <Grid.ColumnDefinitions>
<Image.Effect> <ColumnDefinition></ColumnDefinition>
<BlurEffect Radius="20" /> <ColumnDefinition></ColumnDefinition>
</Image.Effect> </Grid.ColumnDefinitions>
<Image.RenderTransform> <Grid Grid.ColumnSpan="2">
<ScaleTransform ScaleX="1.05" ScaleY="1.05" /> <Image Source="{Binding SongImageSource, Mode=OneWay}" Stretch="UniformToFill" HorizontalAlignment="Center" VerticalAlignment="Center">
</Image.RenderTransform> <Image.Effect>
</Image> <BlurEffect Radius="20" />
<Canvas Background="#99000000"></Canvas> </Image.Effect>
<Image Source="{Binding SongImageSource}" Stretch="Uniform" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="20"> <Image.RenderTransform>
<Image.Effect> <ScaleTransform ScaleX="1.05" ScaleY="1.05" />
<DropShadowEffect BlurRadius="10" Opacity=".4" OffsetX="3" OffsetY="3" /> </Image.RenderTransform>
</Image.Effect> </Image>
</Image> <Canvas Background="#99000000"></Canvas>
</Grid>
<Grid Grid.Column="0">
<Image Source="{Binding SongImageSource}" Stretch="Uniform" HorizontalAlignment="Center" VerticalAlignment="Center" Margin="20">
<Image.Effect>
<DropShadowEffect BlurRadius="10" Opacity=".4" OffsetX="3" OffsetY="3" />
</Image.Effect>
</Image>
</Grid>
<views:PlaylistView Grid.Column="1"></views:PlaylistView>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -0,0 +1,93 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:Harmonia.UI.ViewModels"
xmlns:converter="clr-namespace:Harmonia.UI.Converters"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
DataContext="{x:Static vm:ViewModelLocator.PlaylistViewModel}"
x:Class="Harmonia.UI.Views.PlaylistView"
x:DataType="vm:PlaylistViewModel">
<UserControl.Resources>
<SolidColorBrush x:Key="SongItemTitleBrush" Color="#dddddd"/>
<SolidColorBrush x:Key="SongItemSubtitleBrush" Color="#aaaaaa"/>
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Border.SongImage">
<Setter Property="Width" Value="60"/>
<Setter Property="Height" Value="60"/>
<Setter Property="Margin" Value="0 0 10 0"/>
</Style>
<Style Selector="TextBlock.SongTitle">
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
<Setter Property="Foreground" Value="{StaticResource SongItemTitleBrush}"/>
</Style>
<Style Selector="TextBlock.SongSubtitle">
<Setter Property="FontSize" Value="12"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
<Setter Property="Foreground" Value="{StaticResource SongItemSubtitleBrush}"/>
</Style>
<Style Selector="TextBlock.SongMetaData">
<Setter Property="FontSize" Value="12"/>
<Setter Property="HorizontalAlignment" Value="Right"/>
<Setter Property="Foreground" Value="{StaticResource SongItemTitleBrush}"/>
</Style>
<Style Selector="TextBlock.SongMetaData.Subtitle">
<Setter Property="FontSize" Value="12"/>
<Setter Property="Foreground" Value="{StaticResource SongItemSubtitleBrush}"/>
</Style>
<Style Selector="Grid.Playing TextBlock">
<Setter Property="Foreground" Value="#76b9ed"/>
<!-- Light Blue for Highlight -->
</Style>
</UserControl.Styles>
<Grid Margin="0">
<Canvas Background="#99000000"></Canvas> <!-- Was 99 !-->
<ListBox ItemsSource="{Binding PlaylistSongs}" DoubleTapped="ListBox_DoubleTapped">
<!--<ListBox.Styles>
<Style Selector="ListBoxItem:nth-child(odd):not(:pointerover):not(:selected)">
<Setter Property="Background" Value="#00000000"/>
--><!-- Light Gray --><!--
</Style>
<Style Selector="ListBoxItem:nth-child(even):not(:pointerover):not(:selected)">
<Setter Property="Background" Value="#22AAAAAA"/>
--><!-- Slightly Darker Gray --><!--
</Style>
</ListBox.Styles>-->
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Classes.Playing="{Binding $parent[ListBox].((vm:PlaylistViewModel)DataContext).PlayingSong, Converter={StaticResource PlaylistSongEquality}, ConverterParameter={Binding .}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="70"></ColumnDefinition>
<ColumnDefinition Width="*"></ColumnDefinition>
<ColumnDefinition Width="70"></ColumnDefinition>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
</Grid.RowDefinitions>
<Border Grid.Row="0" Grid.Column="0" Grid.RowSpan="3" Classes="SongImage">
<Grid>
<Image Loaded="Image_Loaded" Unloaded="Image_Unloaded"></Image>
<Canvas Background="#19000000"></Canvas>
</Grid>
</Border>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding Song.Title, Mode=OneWay}" Classes="SongTitle" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding Song.Artists, Mode=OneWay, Converter={StaticResource ArtistsToString}}" Classes="SongSubtitle" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding Song.Album, Mode=OneWay}" Classes="SongSubtitle" />
<TextBlock Grid.Row="0" Grid.Column="2" Text="{Binding Song.Length.TotalSeconds, Mode=OneWay, Converter={StaticResource SecondsToString}}" Classes="SongMetaData" />
<TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding Song.FileType, Mode=OneWay}" Classes="SongMetaData Subtitle" />
<TextBlock Grid.Row="2" Grid.Column="2" Classes="SongMetaData Subtitle">
<Run Text="{Binding Song.BitRate, Mode=OneWay}"></Run><Run Text=" kbps"></Run>
</TextBlock>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</UserControl>

View File

@@ -0,0 +1,91 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Harmonia.Core.Caching;
using Harmonia.Core.Imaging;
using Harmonia.Core.Playlists;
using Harmonia.UI.ViewModels;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Harmonia.UI.Views;
public partial class PlaylistView : UserControl
{
private readonly PlaylistViewModel _viewModel;
private readonly ConcurrentDictionary<int, CancellationTokenSource> _imageCancellationTokens = [];
public PlaylistView()
{
InitializeComponent();
_viewModel = (PlaylistViewModel)DataContext!;
}
private void ListBox_DoubleTapped(object? sender, TappedEventArgs e)
{
if (sender is ListBox listBox && listBox.SelectedItem is PlaylistSong playlistSong)
{
_viewModel.PlaySong(playlistSong);
}
}
private void Image_Loaded(object? sender, RoutedEventArgs e)
{
if (sender is not Image image)
return;
if (image.DataContext is not PlaylistSong playlistSong)
return;
Task.Run(() => DoSomethingAsync(image, playlistSong));
}
private async Task DoSomethingAsync(Image image, PlaylistSong playlistSong)
{
int hashCode = image.GetHashCode();
_imageCancellationTokens.TryGetValue(hashCode, out CancellationTokenSource? cancellationTokenSource);
cancellationTokenSource?.Cancel();
cancellationTokenSource = new();
Bitmap? bitmap = await _viewModel.GetBitmapAsync(playlistSong, cancellationTokenSource.Token);
if (bitmap == null)
return;
await Dispatcher.UIThread.InvokeAsync(() => SetSongImageSource(image, bitmap));
}
private static void SetSongImageSource(Image image, Bitmap bitmap)
{
image.Source = bitmap;
}
private void Image_Unloaded(object? sender, RoutedEventArgs e)
{
if (sender is not Image image)
return;
if (image.DataContext is not PlaylistSong playlistSong)
return;
if (image.Source is Bitmap bitmap)
{
bitmap.Dispose();
}
image.Source = null;
int hashCode = image.GetHashCode();
_imageCancellationTokens.TryGetValue(hashCode, out CancellationTokenSource? cancellationTokenSource);
cancellationTokenSource?.Cancel();
}
}