Compare commits

..

42 Commits

Author SHA1 Message Date
27fd79c392 Updated packages. 2026-09-09 09:36:14 -04:00
e92ef0e28b Added more playlist tests. 2026-09-09 09:34:22 -04:00
8e56ac05f8 Updated Avalonia UI packages. 2026-09-03 10:18:09 -04:00
4fd43c5c18 Embedded default fonts. 2026-08-29 00:05:32 -04:00
b48184ddac Updated packages. 2026-08-28 23:37:25 -04:00
ccea149df0 Disable sleep idle timeout when audio is playing via SetThreadExecutionState. 2026-08-28 23:36:47 -04:00
32547c4f4c Fixed playlist song pasting logic, so that songs can be pasted into an empty playlist (or a playlist with no selected songs). 2026-08-26 10:06:41 -04:00
92f04a4fd3 Updated package. 2026-08-24 19:46:47 -04:00
b9ebd0430d Updated "Play" button style. Removed default background image on the main window. 2026-08-19 22:20:39 -04:00
1bf49b6809 Updated packages. 2026-08-15 22:53:26 -04:00
5302a3c955 Added pointer wheel change functionality on the volume slider. 2026-08-15 22:53:20 -04:00
bc807e3540 Updated packages. 2026-08-13 09:45:11 -04:00
c8ce4ca7f0 Added volume control. Updated styling. 2026-08-13 00:29:35 -04:00
321db65154 Added new styling to the player view. 2026-08-11 00:15:39 -04:00
23aff91ba0 Updated NSubstitute package. 2026-08-10 09:13:50 -04:00
91e4d88643 Added playlists view context menu for rename, lock/unlock, and delete. 2026-08-09 17:30:38 -04:00
cf8a40ee52 Fixed the "set" Playlist property (order of operations issue). 2026-08-09 14:03:14 -04:00
895cfdc5f7 Fixed issue with maintaining audio player "pause" state between application restarts. 2026-08-09 11:51:44 -04:00
2be865d445 Fixed PlaylistDetailViewModels's "Playlist" object not properly synchronizing (events, locking) to the playlist manager's current playlist at constructor time. 2026-08-09 11:28:45 -04:00
afcae0761b Included M4A as a supported format. 2026-08-08 18:24:28 -04:00
0942e3e5ad Added tracking/capturing/restoration of audio playback state and position. 2026-08-08 16:37:55 -04:00
c262ee4caa Added application session services, along with initial restore and tracker implementations. 2026-08-06 02:11:47 -04:00
75f820ed7b Updated certain events to have more concrete event arguments. Added try-catch block around playlist save logic. Added DispatchQueue where necessary. 2026-08-05 22:57:28 -04:00
1d45826c38 Highlight active playlist. Various optimizations and fixes. 2026-08-05 00:50:56 -04:00
3c3cf37cd5 When adding a new playlist, prompt user for the playlist name. 2026-08-03 23:52:56 -04:00
cd6f9c382e Added playlist renaming functionality. 2026-08-03 23:49:21 -04:00
de16ac6240 Added in more locking/unlocking logic. 2026-08-03 22:57:40 -04:00
2b78ac5667 Added randomize/reverse songs logic. Added initial UI-side locking logic. 2026-08-03 22:38:16 -04:00
99978c62c4 Implemented tag refresh on a playlist. Made audio file scanning truly asynchronous. Added basic playlist sorting functionality. 2026-08-02 23:34:04 -04:00
7aaf36f7b2 Updated Avalonia UI packages. 2026-07-31 10:41:26 -04:00
16b87d255e Show total songs and total duration for each playlist. 2026-07-31 09:04:14 -04:00
ea5b428a65 Make "Playlist" observable (for scroll to top feature). 2026-07-31 09:03:43 -04:00
8864da7a75 Scroll to top of the playlist on playlist change. 2026-07-31 09:03:00 -04:00
6484f2120e Removed unused code. 2026-07-31 08:58:34 -04:00
62e53a29d4 Added playlists view. Added messaging service. Added logic for adding and deleting playlists. 2026-07-28 00:20:41 -04:00
7cfcbf26cc Fixed loading multiple songs issue. 2026-07-28 00:19:30 -04:00
a85c65e833 Removed "PlaylistSongs" field, in order to maintain one source of truth (Playlist.Songs). No not allow reordering when a filter is present on the playlist. 2026-07-26 23:44:06 -04:00
c334a4166f Updated playlist view name. Added custom title bar. 2026-07-26 22:37:17 -04:00
140d4ea49a Updated packages. 2026-07-26 17:08:07 -04:00
aa9861162d Optimized filtering playlist songs. 2026-07-26 17:07:49 -04:00
ffe3802b25 Moved fetching image to background thread. 2026-07-26 17:07:14 -04:00
3fd9e8a00c Fixed removing locked object. 2026-07-26 17:06:33 -04:00
73 changed files with 3019 additions and 801 deletions

View File

@@ -60,7 +60,7 @@ public abstract class Cache<TKey, TValue> : ICache<TKey, TValue> where TKey : no
if (lockAcquired) if (lockAcquired)
lockObject.Release(); lockObject.Release();
_locks.TryRemove(lockObject, out _); _locks.TryRemove(actualKey, out _);
if (throttlerAcquired) if (throttlerAcquired)
_throttler.Release(); _throttler.Release();

View File

@@ -6,6 +6,7 @@ namespace Harmonia.Core.Engine;
public class BassAudioEngine : IAudioEngine, IDisposable public class BassAudioEngine : IAudioEngine, IDisposable
{ {
private readonly BaseMediaPlayer _mediaPlayer; private readonly BaseMediaPlayer _mediaPlayer;
private readonly SemaphoreSlim _loadLock = new(1, 1);
private CancellationTokenSource? _cancellationTokenSource; private CancellationTokenSource? _cancellationTokenSource;
@@ -96,7 +97,7 @@ public class BassAudioEngine : IAudioEngine, IDisposable
List<string> supportedFormats = [.. Bass.SupportedFormats.Split(';')]; List<string> supportedFormats = [.. Bass.SupportedFormats.Split(';')];
//supportedFormats.Add(".aac"); //supportedFormats.Add(".aac");
//supportedFormats.Add(".m4a"); supportedFormats.Add(".m4a");
supportedFormats.Add("*.flac"); supportedFormats.Add("*.flac");
//supportedFormats.Add(".opus"); //supportedFormats.Add(".opus");
//supportedFormats.Add(".wma"); //supportedFormats.Add(".wma");
@@ -136,27 +137,48 @@ public class BassAudioEngine : IAudioEngine, IDisposable
private async Task<bool> LoadWaveSourceAsync(string fileName) private async Task<bool> LoadWaveSourceAsync(string fileName)
{ {
_cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Cancel();
_cancellationTokenSource = new CancellationTokenSource();
CancellationToken token = _cancellationTokenSource.Token; CancellationTokenSource cancellationTokenSource = new();
_cancellationTokenSource = cancellationTokenSource;
CancellationToken token = cancellationTokenSource.Token;
// Serialize loads so overlapping MediaPlayer.LoadAsync calls can't
// race and leave orphaned streams playing.
await _loadLock.WaitAsync(CancellationToken.None);
try try
{
await _mediaPlayer.LoadAsync(fileName);
}
catch (Exception ex)
{ {
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
return false; return false;
//return new Result(State.Exception, ex.Message); try
throw new Exception("An error occurred - " + fileName, ex); {
await _mediaPlayer.LoadAsync(fileName);
}
catch (Exception ex)
{
if (token.IsCancellationRequested)
return false;
//return new Result(State.Exception, ex.Message);
throw new Exception("An error occurred - " + fileName, ex);
}
if (token.IsCancellationRequested)
{
// A newer load superseded this one; make sure this stream
// doesn't keep playing in the background.
_mediaPlayer.Stop();
return false;
}
return true;
}
finally
{
_loadLock.Release();
} }
if (token.IsCancellationRequested)
return false;
return true;
} }
private void UpdateSource(string fileName) private void UpdateSource(string fileName)

View File

@@ -9,10 +9,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="ManagedBass" Version="4.0.2" /> <PackageReference Include="ManagedBass" Version="4.0.2" />
<PackageReference Include="ManagedBass.Flac" Version="4.0.2" /> <PackageReference Include="ManagedBass.Flac" Version="4.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.12" />
<PackageReference Include="TagLibSharp" Version="2.3.0" /> <PackageReference Include="TagLibSharp" Version="2.3.0" />
</ItemGroup> </ItemGroup>

View File

@@ -9,6 +9,8 @@ public class AudioPlayer : IAudioPlayer
private readonly IAudioEngine _audioEngine; private readonly IAudioEngine _audioEngine;
private readonly IPlaylistManager _playlistManager; private readonly IPlaylistManager _playlistManager;
private int _loadVersion;
private Playlist? _playlist; private Playlist? _playlist;
public Playlist? Playlist public Playlist? Playlist
{ {
@@ -18,9 +20,13 @@ public class AudioPlayer : IAudioPlayer
} }
protected set protected set
{ {
Playlist? oldPlaylist = _playlist;
_playlist = value; _playlist = value;
NotifyPropertyChanged(nameof(Playlist)); NotifyPropertyChanged(nameof(Playlist));
PlaylistChanged?.Invoke(this, new());
PlaylistChangedEventArgs eventArgs = new(oldPlaylist, value);
PlaylistChanged?.Invoke(this, eventArgs);
} }
} }
@@ -35,9 +41,13 @@ public class AudioPlayer : IAudioPlayer
} }
protected set protected set
{ {
PlaylistSong? oldSong = _playingSong;
_playingSong = value; _playingSong = value;
NotifyPropertyChanged(nameof(PlayingSong)); NotifyPropertyChanged(nameof(PlayingSong));
PlayingSongChanged?.Invoke(this, new());
PlayingSongChangedEventArgs eventArgs = new(oldSong, value);
PlayingSongChanged?.Invoke(this, eventArgs);
} }
} }
@@ -113,8 +123,8 @@ public class AudioPlayer : IAudioPlayer
protected virtual int PreviousSongSecondsThreshold => 5; protected virtual int PreviousSongSecondsThreshold => 5;
public event EventHandler? PlaylistChanged; public event EventHandler<PlaylistChangedEventArgs>? PlaylistChanged;
public event EventHandler? PlayingSongChanged; public event EventHandler<PlayingSongChangedEventArgs>? PlayingSongChanged;
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
public AudioPlayer(IAudioEngine audioEngine, IPlaylistManager playlistManager) public AudioPlayer(IAudioEngine audioEngine, IPlaylistManager playlistManager)
@@ -171,7 +181,7 @@ public class AudioPlayer : IAudioPlayer
return; return;
} }
int currentIndex = Playlist.Songs.IndexOf(CurrentPlaylistSong); int currentIndex = Playlist.IndexOf(CurrentPlaylistSong);
int nextIndex = currentIndex + 1; int nextIndex = currentIndex + 1;
if (nextIndex > Playlist.Songs.Count - 1) if (nextIndex > Playlist.Songs.Count - 1)
@@ -198,7 +208,7 @@ public class AudioPlayer : IAudioPlayer
return; return;
} }
int currentIndex = Playlist.Songs.IndexOf(CurrentPlaylistSong); int currentIndex = Playlist.IndexOf(CurrentPlaylistSong);
int nextIndex = currentIndex - 1; int nextIndex = currentIndex - 1;
if (nextIndex < 0) if (nextIndex < 0)
@@ -231,7 +241,7 @@ public class AudioPlayer : IAudioPlayer
{ {
if (Playlist == null || Playlist.Songs.Contains(song) == false) if (Playlist == null || Playlist.Songs.Contains(song) == false)
{ {
Playlist? newPlaylist = _playlistManager.GetPlaylist(song); Playlist? newPlaylist = _playlistManager.FindPlaylistContaining(song);
if (newPlaylist == null) if (newPlaylist == null)
return false; return false;
@@ -241,8 +251,15 @@ public class AudioPlayer : IAudioPlayer
CurrentPlaylistSong = song; CurrentPlaylistSong = song;
int loadVersion = Interlocked.Increment(ref _loadVersion);
bool isLoaded = await TryLoadAsync(song); bool isLoaded = await TryLoadAsync(song);
// A newer load request was started while this one was in flight;
// abandon this one so only the latest request controls playback.
if (loadVersion != Volatile.Read(ref _loadVersion))
return false;
if (isLoaded == false) if (isLoaded == false)
{ {
if (mode == PlaybackMode.LoadAndPlay) if (mode == PlaybackMode.LoadAndPlay)

View File

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

View File

@@ -0,0 +1,9 @@
using Harmonia.Core.Playlists;
namespace Harmonia.Core.Player;
public class PlayingSongChangedEventArgs(PlaylistSong? oldSong, PlaylistSong? newSong) : EventArgs
{
public PlaylistSong? OldSong { get; } = oldSong;
public PlaylistSong? NewSong { get; } = newSong;
}

View File

@@ -0,0 +1,9 @@
using Harmonia.Core.Playlists;
namespace Harmonia.Core.Player;
//public class PlaylistChangedEventArgs(Playlist? oldPlaylist, Playlist? newPlaylist) : EventArgs
//{
// public Playlist? OldPlaylist { get; } = oldPlaylist;
// public Playlist? NewPlaylist { get; } = newPlaylist;
//}

View File

@@ -8,9 +8,11 @@ public interface IPlaylistManager
Task InitializeAsync(); Task InitializeAsync();
Task<Playlist> AddPlaylistAsync(); Task<Playlist> AddPlaylistAsync();
void RemovePlaylist(Playlist playlist); void RemovePlaylist(Playlist playlist);
Playlist? GetPlaylist(PlaylistSong playlistSong); Playlist? FindPlaylistContaining(PlaylistSong playlistSong);
Playlist? FindPlaylistContaining(string playlistSongUID);
event EventHandler? CurrentPlaylistChanged; event EventHandler<PlaylistChangedEventArgs>? CurrentPlaylistChanged;
event EventHandler<PlaylistAddedEventArgs> PlaylistAdded; event EventHandler<PlaylistAddedEventArgs> PlaylistAdded;
event EventHandler<PlaylistRemovedEventArgs> PlaylistRemoved; event EventHandler<PlaylistRemovedEventArgs> PlaylistRemoved;
event EventHandler<PlaylistSaveFailedEventArgs>? PlaylistSaveFailed;
} }

View File

@@ -4,10 +4,5 @@ namespace Harmonia.Core.Playlists;
public interface IPlaylistRepository : IRepository<Playlist> public interface IPlaylistRepository : IRepository<Playlist>
{ {
Playlist? GetPlaylist(PlaylistSong playlistSong);
//void AddPlaylist();
//void RemovePlaylist(Playlist playlist);
//event EventHandler<PlaylistAddedEventArgs> PlaylistAdded;
//event EventHandler<PlaylistRemovedEventArgs> PlaylistRemoved;
} }

View File

@@ -5,15 +5,102 @@ namespace Harmonia.Core.Playlists;
public class Playlist public class Playlist
{ {
private readonly List<PlaylistSong> _songs = [];
private readonly List<GroupOption> _groupOptions = [];
private readonly List<SortOption> _sortOptions = [];
public string UID { get; init; } = Guid.NewGuid().ToString(); public string UID { get; init; } = Guid.NewGuid().ToString();
public string? Name { get; set; } public string? Name { get; private set; }
public List<PlaylistSong> Songs { get; init; } = []; // TODO: Change to "private init" once deserialization is fixed public IReadOnlyList<PlaylistSong> Songs => _songs;
public List<GroupOption> GroupOptions { get; set; } = []; public IReadOnlyList<GroupOption> GroupOptions => _groupOptions;
public List<SortOption> SortOptions { get; set; } = []; public IReadOnlyList<SortOption> SortOptions => _sortOptions;
public bool IsLocked { get; set; } public bool IsLocked { get; private set; }
public event EventHandler<PlaylistUpdatedEventArgs>? PlaylistUpdated; public event EventHandler<PlaylistUpdatedEventArgs>? PlaylistUpdated;
public Playlist()
{
}
public Playlist(string? name)
{
Name = name;
}
internal static Playlist Restore(string uid, string? name, IEnumerable<PlaylistSong> songs, IEnumerable<GroupOption> groupOptions, IEnumerable<SortOption> sortOptions, bool isLocked)
{
Playlist playlist = new(name)
{
UID = uid,
IsLocked = isLocked
};
playlist._songs.AddRange(songs);
playlist._groupOptions.AddRange(groupOptions);
playlist._sortOptions.AddRange(sortOptions);
return playlist;
}
public int IndexOf(PlaylistSong playlistSong)
{
return _songs.IndexOf(playlistSong);
}
public void SetName(string name)
{
if (string.Equals(Name, name, StringComparison.Ordinal))
return;
Name = name;
PlaylistUpdatedEventArgs eventArgs = new()
{
Action = PlaylistUpdateAction.Rename,
Index = -1,
Count = 0,
Songs = []
};
PlaylistUpdated?.Invoke(this, eventArgs);
}
public void Lock()
{
if (IsLocked)
return;
IsLocked = true;
PlaylistUpdatedEventArgs eventArgs = new()
{
Action = PlaylistUpdateAction.Lock,
Index = -1,
Count = 0,
Songs = []
};
PlaylistUpdated?.Invoke(this, eventArgs);
}
public void Unlock()
{
if (IsLocked == false)
return;
IsLocked = false;
PlaylistUpdatedEventArgs eventArgs = new()
{
Action = PlaylistUpdateAction.Unlock,
Index = -1,
Count = 0,
Songs = []
};
PlaylistUpdated?.Invoke(this, eventArgs);
}
public void AddSong(Song song, int? index = null) public void AddSong(Song song, int? index = null)
{ {
AddSongs([song], index); AddSongs([song], index);
@@ -34,9 +121,9 @@ public class Playlist
if (playlistSongs.Length == 0) if (playlistSongs.Length == 0)
return; return;
int insertIndex = index ?? Songs.Count; int insertIndex = index ?? _songs.Count;
Songs.InsertRange(insertIndex, playlistSongs); _songs.InsertRange(insertIndex, playlistSongs);
PlaylistUpdatedEventArgs eventArgs = new() PlaylistUpdatedEventArgs eventArgs = new()
{ {
@@ -51,7 +138,7 @@ public class Playlist
public void MoveSong(PlaylistSong playlistSong, int newIndex) public void MoveSong(PlaylistSong playlistSong, int newIndex)
{ {
int currentIndex = Songs.IndexOf(playlistSong); int currentIndex = _songs.IndexOf(playlistSong);
MoveSong(currentIndex, newIndex); MoveSong(currentIndex, newIndex);
} }
@@ -64,10 +151,10 @@ public class Playlist
if (oldIndex == newIndex) if (oldIndex == newIndex)
return; return;
PlaylistSong playlistSong = Songs[oldIndex]; PlaylistSong playlistSong = _songs[oldIndex];
Songs.Remove(playlistSong); _songs.Remove(playlistSong);
Songs.Insert(newIndex, playlistSong); _songs.Insert(newIndex, playlistSong);
PlaylistUpdatedEventArgs eventArgs = new() PlaylistUpdatedEventArgs eventArgs = new()
{ {
@@ -83,9 +170,12 @@ public class Playlist
public void SortSongs(PlaylistSong[] playlistSongs, SortOption[] sortOptions) public void SortSongs(PlaylistSong[] playlistSongs, SortOption[] sortOptions)
{ {
if (IsLocked)
return;
Dictionary<int, PlaylistSong> oldPlaylistSongs = playlistSongs Dictionary<int, PlaylistSong> oldPlaylistSongs = playlistSongs
.OrderBy(Songs.IndexOf) .OrderBy(_songs.IndexOf)
.ToDictionary(Songs.IndexOf, playlistSong => playlistSong); .ToDictionary(_songs.IndexOf, playlistSong => playlistSong);
Song[] songs = [.. playlistSongs.Select(playlistSong => playlistSong.Song)]; Song[] songs = [.. playlistSongs.Select(playlistSong => playlistSong.Song)];
Song[] sortedSongs = [.. songs.SortBy(sortOptions)]; Song[] sortedSongs = [.. songs.SortBy(sortOptions)];
@@ -101,8 +191,8 @@ public class Playlist
if (newPlaylistSong == playlistSong) if (newPlaylistSong == playlistSong)
continue; continue;
Songs.RemoveAt(index); _songs.RemoveAt(index);
Songs.Insert(index, newPlaylistSong); _songs.Insert(index, newPlaylistSong);
} }
PlaylistUpdatedEventArgs eventArgs = new() PlaylistUpdatedEventArgs eventArgs = new()
@@ -116,6 +206,50 @@ public class Playlist
PlaylistUpdated?.Invoke(this, eventArgs); PlaylistUpdated?.Invoke(this, eventArgs);
} }
public void Randomize(PlaylistSong[] playlistSongs)
{
if (IsLocked)
return;
int[] originalIndexes = [.. playlistSongs.Select(playlistSong => _songs.IndexOf(playlistSong))];
PlaylistSong[] shuffledSongs = [.. playlistSongs.Shuffle()];
for (int i = 0; i < originalIndexes.Length; i++)
_songs[originalIndexes[i]] = shuffledSongs[i];
PlaylistUpdatedEventArgs eventArgs = new()
{
Action = PlaylistUpdateAction.Reset,
Index = -1,
Count = playlistSongs.Length,
Songs = playlistSongs
};
PlaylistUpdated?.Invoke(this, eventArgs);
}
public void Reverse(PlaylistSong[] playlistSongs)
{
if (IsLocked)
return;
int[] originalIndexes = [.. playlistSongs.Select(playlistSong => _songs.IndexOf(playlistSong))];
PlaylistSong[] reversedSongs = [.. originalIndexes.Select(i => _songs[i]).Reverse()];
for (int i = 0; i < originalIndexes.Length; i++)
_songs[originalIndexes[i]] = reversedSongs[i];
PlaylistUpdatedEventArgs eventArgs = new()
{
Action = PlaylistUpdateAction.Reset,
Index = -1,
Count = playlistSongs.Length,
Songs = playlistSongs
};
PlaylistUpdated?.Invoke(this, eventArgs);
}
public void RemoveSong(int index) public void RemoveSong(int index)
{ {
RemoveSongs(index, 1); RemoveSongs(index, 1);
@@ -123,7 +257,7 @@ public class Playlist
public void RemoveSongs(int index, int count) public void RemoveSongs(int index, int count)
{ {
PlaylistSong[] playlistSongs = [.. Songs.GetRange(index, count)]; PlaylistSong[] playlistSongs = [.. _songs.GetRange(index, count)];
RemoveSongs(playlistSongs); RemoveSongs(playlistSongs);
} }
@@ -142,7 +276,7 @@ public class Playlist
foreach (PlaylistSong playlistSong in playlistSongs) foreach (PlaylistSong playlistSong in playlistSongs)
{ {
if (Songs.Remove(playlistSong)) if (_songs.Remove(playlistSong))
{ {
removedSongs.Add(playlistSong); removedSongs.Add(playlistSong);
} }
@@ -164,6 +298,8 @@ public class Playlist
public void ImportTags(Song[] songs) public void ImportTags(Song[] songs)
{ {
List<PlaylistSong> updatedPlaylistSongs = [];
foreach (Song song in songs) foreach (Song song in songs)
{ {
PlaylistSong[] playlistSongs = [.. Songs.Where(playlistSong => PlaylistSong[] playlistSongs = [.. Songs.Where(playlistSong =>
@@ -171,10 +307,24 @@ public class Playlist
foreach (PlaylistSong playlistSong in playlistSongs) foreach (PlaylistSong playlistSong in playlistSongs)
{ {
//playlistSong.Song = song; playlistSong.Song.Update(song);
//playlistSong.Song.Update(song);
} }
updatedPlaylistSongs.AddRange(playlistSongs);
} }
if (updatedPlaylistSongs.Count == 0)
return;
PlaylistUpdatedEventArgs eventArgs = new()
{
Action = PlaylistUpdateAction.Refresh,
Index = -1,
Count = updatedPlaylistSongs.Count,
Songs = [.. updatedPlaylistSongs]
};
PlaylistUpdated?.Invoke(this, eventArgs);
} }
public void RemoveMissingSongs() public void RemoveMissingSongs()

View File

@@ -0,0 +1,7 @@
namespace Harmonia.Core.Playlists;
public class PlaylistChangedEventArgs(Playlist? oldPlaylist, Playlist? newPlaylist) : EventArgs
{
public readonly Playlist? OldPlaylist = oldPlaylist;
public readonly Playlist? NewPlaylist = newPlaylist;
}

View File

@@ -0,0 +1,60 @@
using Harmonia.Core.Models;
namespace Harmonia.Core.Playlists;
internal sealed record PlaylistDto
{
public string UID { get; init; } = Guid.NewGuid().ToString();
public string? Name { get; init; }
public List<PlaylistSongDto> Songs { get; init; } = [];
public List<GroupOption> GroupOptions { get; init; } = [];
public List<SortOption> SortOptions { get; init; } = [];
public bool IsLocked { get; init; }
public static PlaylistDto FromPlaylist(Playlist playlist)
{
return new PlaylistDto
{
UID = playlist.UID,
Name = playlist.Name,
Songs = [.. playlist.Songs.Select(PlaylistSongDto.FromPlaylistSong)],
GroupOptions = [.. playlist.GroupOptions],
SortOptions = [.. playlist.SortOptions],
IsLocked = playlist.IsLocked
};
}
public Playlist ToPlaylist()
{
return Playlist.Restore(
UID,
Name,
Songs.Select(song => song.ToPlaylistSong()),
GroupOptions,
SortOptions,
IsLocked);
}
}
internal sealed record PlaylistSongDto
{
public string UID { get; init; } = Guid.NewGuid().ToString();
public required Song Song { get; init; }
public static PlaylistSongDto FromPlaylistSong(PlaylistSong playlistSong)
{
return new PlaylistSongDto
{
UID = playlistSong.UID,
Song = playlistSong.Song
};
}
public PlaylistSong ToPlaylistSong()
{
return new PlaylistSong(Song)
{
UID = UID
};
}
}

View File

@@ -4,6 +4,7 @@ public class PlaylistManager : IPlaylistManager
{ {
private readonly IPlaylistRepository playlistRepository; private readonly IPlaylistRepository playlistRepository;
private readonly List<Playlist> _playlists = []; private readonly List<Playlist> _playlists = [];
private readonly Dictionary<string, Playlist> _playlistsBySongUid = [];
public IReadOnlyList<Playlist> Playlists => _playlists; public IReadOnlyList<Playlist> Playlists => _playlists;
@@ -16,14 +17,17 @@ public class PlaylistManager : IPlaylistManager
} }
set set
{ {
Playlist? oldPlaylist = _currentPlaylist;
_currentPlaylist = value; _currentPlaylist = value;
CurrentPlaylistChanged?.Invoke(this, new());
CurrentPlaylistChanged?.Invoke(this, new(oldPlaylist, _currentPlaylist));
} }
} }
public event EventHandler? CurrentPlaylistChanged; public event EventHandler<PlaylistChangedEventArgs>? CurrentPlaylistChanged;
public event EventHandler<PlaylistAddedEventArgs>? PlaylistAdded; public event EventHandler<PlaylistAddedEventArgs>? PlaylistAdded;
public event EventHandler<PlaylistRemovedEventArgs>? PlaylistRemoved; public event EventHandler<PlaylistRemovedEventArgs>? PlaylistRemoved;
public event EventHandler<PlaylistSaveFailedEventArgs>? PlaylistSaveFailed;
public PlaylistManager(IPlaylistRepository playlistRepository) public PlaylistManager(IPlaylistRepository playlistRepository)
{ {
@@ -37,6 +41,11 @@ public class PlaylistManager : IPlaylistManager
foreach (Playlist playlist in _playlists) foreach (Playlist playlist in _playlists)
{ {
playlist.PlaylistUpdated += OnPlaylistUpdated; playlist.PlaylistUpdated += OnPlaylistUpdated;
foreach (PlaylistSong song in playlist.Songs)
{
_playlistsBySongUid[song.UID] = playlist;
}
} }
CurrentPlaylist = _playlists.Count > 0 ? _playlists[0] : await AddPlaylistAsync(); CurrentPlaylist = _playlists.Count > 0 ? _playlists[0] : await AddPlaylistAsync();
@@ -44,15 +53,17 @@ public class PlaylistManager : IPlaylistManager
public async Task<Playlist> AddPlaylistAsync() public async Task<Playlist> AddPlaylistAsync()
{ {
Playlist playlist = new() Playlist playlist = new("New Playlist");
{
Name = "New Playlist"
};
playlist.PlaylistUpdated += OnPlaylistUpdated; playlist.PlaylistUpdated += OnPlaylistUpdated;
_playlists.Add(playlist); _playlists.Add(playlist);
await playlistRepository.SaveAsync(playlist); foreach (PlaylistSong song in playlist.Songs)
{
_playlistsBySongUid[song.UID] = playlist;
}
await SavePlaylistAsync(playlist);
PlaylistAdded?.Invoke(this, new(playlist)); PlaylistAdded?.Invoke(this, new(playlist));
@@ -64,14 +75,29 @@ public class PlaylistManager : IPlaylistManager
playlist.PlaylistUpdated -= OnPlaylistUpdated; playlist.PlaylistUpdated -= OnPlaylistUpdated;
_playlists.Remove(playlist); _playlists.Remove(playlist);
foreach (PlaylistSong song in playlist.Songs)
{
_playlistsBySongUid.Remove(song.UID);
}
playlistRepository.Delete(playlist); playlistRepository.Delete(playlist);
if (CurrentPlaylist == playlist)
{
CurrentPlaylist = _playlists.Count > 0 ? _playlists[0] : null;
}
PlaylistRemoved?.Invoke(this, new(playlist)); PlaylistRemoved?.Invoke(this, new(playlist));
} }
public Playlist? GetPlaylist(PlaylistSong playlistSong) public Playlist? FindPlaylistContaining(PlaylistSong playlistSong)
{ {
return _playlists.FirstOrDefault(playlist => playlist.Songs.Any(song => song.UID == playlistSong.UID)); return _playlistsBySongUid.GetValueOrDefault(playlistSong.UID);
}
public Playlist? FindPlaylistContaining(string playlistSongUID)
{
return _playlistsBySongUid.GetValueOrDefault(playlistSongUID);
} }
private async void OnPlaylistUpdated(object? sender, PlaylistUpdatedEventArgs e) private async void OnPlaylistUpdated(object? sender, PlaylistUpdatedEventArgs e)
@@ -79,6 +105,34 @@ public class PlaylistManager : IPlaylistManager
if (sender is not Playlist playlist) if (sender is not Playlist playlist)
return; return;
await playlistRepository.SaveAsync(playlist); switch (e.Action)
{
case PlaylistUpdateAction.Add:
foreach (PlaylistSong song in e.Songs)
{
_playlistsBySongUid[song.UID] = playlist;
}
break;
case PlaylistUpdateAction.Remove:
foreach (PlaylistSong song in e.Songs)
{
_playlistsBySongUid.Remove(song.UID);
}
break;
}
await SavePlaylistAsync(playlist);
}
private async Task SavePlaylistAsync(Playlist playlist)
{
try
{
await playlistRepository.SaveAsync(playlist);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
PlaylistSaveFailed?.Invoke(this, new(playlist, ex));
}
} }
} }

View File

@@ -1,35 +1,32 @@
using Harmonia.Core.Data; using Harmonia.Core.Data;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Harmonia.Core.Playlists; namespace Harmonia.Core.Playlists;
public class PlaylistRepository : JsonFileRepository<Playlist>, IPlaylistRepository public class PlaylistRepository : JsonFileRepository<Playlist>, IPlaylistRepository
{ {
private static readonly JsonSerializerOptions _options = new()
{
WriteIndented = true,
IgnoreReadOnlyProperties = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
protected override string DirectoryName => Path.Combine("Playlists"); protected override string DirectoryName => Path.Combine("Playlists");
//public PlaylistRepository() protected override async Task<Playlist> DeserializeAsync(Stream stream)
//{
// List<Playlist> playlists = Get();
// foreach (Playlist playlist in playlists)
// {
// playlist.PlaylistUpdated += OnPlaylistUpdated;
// }
// if (playlists.Count == 0)
// AddPlaylist();
//}
//private void OnPlaylistUpdated(object? sender, PlaylistUpdatedEventArgs e)
//{
// if (sender is not Playlist playlist)
// return;
// Save(playlist);
//}
public Playlist? GetPlaylist(PlaylistSong playlistSong)
{ {
return Get().FirstOrDefault(playlist => playlist.Songs.Contains(playlistSong)); PlaylistDto dto = await JsonSerializer.DeserializeAsync<PlaylistDto>(stream, _options) ?? new();
return dto.ToPlaylist();
}
protected override Task<string> SerializeAsync(Playlist playlist)
{
PlaylistDto dto = PlaylistDto.FromPlaylist(playlist);
return Task.FromResult(JsonSerializer.Serialize(dto, _options));
} }
protected override string GetNewFileName() protected override string GetNewFileName()
@@ -45,28 +42,4 @@ public class PlaylistRepository : JsonFileRepository<Playlist>, IPlaylistReposit
throw new Exception("Unable to determine new fileName"); throw new Exception("Unable to determine new fileName");
} }
//public event EventHandler<PlaylistAddedEventArgs>? PlaylistAdded;
//public event EventHandler<PlaylistRemovedEventArgs>? PlaylistRemoved;
//public void AddPlaylist()
//{
// Playlist playlist = new()
// {
// Name = "New Playlist"
// };
// playlist.PlaylistUpdated += OnPlaylistUpdated;
// Save(playlist);
// PlaylistAdded?.Invoke(this, new(playlist));
//}
//public void RemovePlaylist(Playlist playlist)
//{
// playlist.PlaylistUpdated -= OnPlaylistUpdated;
// Delete(playlist);
// PlaylistRemoved?.Invoke(this, new(playlist));
//}
} }

View File

@@ -0,0 +1,7 @@
namespace Harmonia.Core.Playlists;
public class PlaylistSaveFailedEventArgs(Playlist playlist, Exception exception) : EventArgs
{
public Playlist Playlist { get; } = playlist;
public Exception Exception { get; } = exception;
}

View File

@@ -17,5 +17,12 @@ public enum PlaylistUpdateAction
/// <summary> /// <summary>
/// The contents of the collection changed dramatically. /// The contents of the collection changed dramatically.
/// </summary> /// </summary>
Reset Reset,
/// <summary>
/// The tags of the songs in the collection were refreshed.
/// </summary>
Refresh,
Rename,
Lock,
Unlock
} }

View File

@@ -9,22 +9,19 @@ public class AudioFileScanner(IAudioEngine audioEngine, ITagResolver tagResolver
{ {
public async Task<Song[]> GetSongsAsync(string[] fileNames, CancellationToken cancellationToken) public async Task<Song[]> GetSongsAsync(string[] fileNames, CancellationToken cancellationToken)
{ {
List<Song> songs = []; Song?[] songs = new Song?[fileNames.Length];
foreach (string fileName in fileNames) await Parallel.ForEachAsync(Enumerable.Range(0, fileNames.Length), cancellationToken, async (index, token) =>
{ {
string fileName = fileNames[index];
if (string.IsNullOrWhiteSpace(fileName) || File.Exists(fileName) == false) if (string.IsNullOrWhiteSpace(fileName) || File.Exists(fileName) == false)
continue; return;
Song? song = await GetSongAsync(fileName, cancellationToken); songs[index] = await GetSongAsync(fileName, token);
});
if (song == null) return [.. songs.Where(song => song != null)!];
continue;
songs.Add(song);
}
return [.. songs];
} }
private async Task<Song> GetSongAsync(string fileName, CancellationToken cancellationToken) private async Task<Song> GetSongAsync(string fileName, CancellationToken cancellationToken)

View File

@@ -21,7 +21,7 @@ internal class TestAudioPlayer(IAudioEngine audioEngine, IPlaylistManager playli
if (Playlist == null || PlayingSong == null) if (Playlist == null || PlayingSong == null)
return -1; return -1;
return Playlist.Songs.IndexOf(PlayingSong); return Playlist.IndexOf(PlayingSong);
} }
} }
@@ -54,10 +54,7 @@ public class AudioPlayerTests
new Song() { FileName = "Song7.mp3" } new Song() { FileName = "Song7.mp3" }
]; ];
Playlist playlist = new() Playlist playlist = new("Playlist1");
{
Name = "Playlist1"
};
playlist.AddSongs(songs); playlist.AddSongs(songs);
@@ -65,7 +62,7 @@ public class AudioPlayerTests
_playlistManager = Substitute.For<IPlaylistManager>(); _playlistManager = Substitute.For<IPlaylistManager>();
_playlistManager.Playlists.Returns([playlist]); _playlistManager.Playlists.Returns([playlist]);
_playlistManager.GetPlaylist(Arg.Any<PlaylistSong>()).Returns(playlist); _playlistManager.FindPlaylistContaining(Arg.Any<PlaylistSong>()).Returns(playlist);
_audioPlayer = new TestAudioPlayer(_audioEngine, _playlistManager); _audioPlayer = new TestAudioPlayer(_audioEngine, _playlistManager);
_audioPlayer.SetPlaylist(playlist); _audioPlayer.SetPlaylist(playlist);

View File

@@ -16,14 +16,14 @@
<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="Microsoft.NET.Test.Sdk" Version="18.8.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.0" />
<PackageReference Include="NSubstitute" Version="6.0.0" /> <PackageReference Include="NSubstitute" Version="6.2.0" />
<PackageReference Include="Shouldly" Version="4.3.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<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="3.2.2" /> <PackageReference Include="xunit.v3" Version="4.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -8,6 +8,32 @@ namespace Harmonia.Tests;
public class PlaylistTests public class PlaylistTests
{ {
[Fact]
public void Create_Playlist()
{
Playlist playlist = new();
playlist.Name.ShouldBeNull();
playlist.Songs.ShouldBeEmpty();
}
[Fact]
public void Create_Playlist_With_Name()
{
Playlist playlist = new("My Playlist");
playlist.Name.ShouldBe("My Playlist");
playlist.Songs.ShouldBeEmpty();
}
[Fact]
public void Set_Playlist_Name()
{
Playlist playlist = new();
playlist.Name.ShouldBeNull();
playlist.SetName("My Playlist");
playlist.Name.ShouldBe("My Playlist");
}
[Fact] [Fact]
public void Add_Songs() public void Add_Songs()
{ {
@@ -37,6 +63,70 @@ public class PlaylistTests
playlist.Songs[1].Song.FileName.ShouldBe("Song5.mp3"); playlist.Songs[1].Song.FileName.ShouldBe("Song5.mp3");
} }
[Fact]
public void Move_Songs()
{
Playlist playlist = new();
Song[] songs =
[
new Song() { FileName = "Song1.mp3" },
new Song() { FileName = "Song2.mp3" },
new Song() { FileName = "Song3.mp3" },
new Song() { FileName = "Song4.mp3" },
new Song() { FileName = "Song5.mp3" },
];
playlist.AddSongs(songs);
playlist.MoveSong(2, 3);
string[] expectedFileNames =
[
"Song1.mp3",
"Song2.mp3",
"Song4.mp3",
"Song3.mp3",
"Song5.mp3"
];
playlist.Songs.Select(x => x.Song.FileName).ToArray()
.ShouldBeEquivalentTo(expectedFileNames);
}
[Fact]
public void Remove_Duplicate_Songs()
{
Playlist playlist = new();
Song[] songs =
[
new Song() { FileName = "Song1.mp3" },
new Song() { FileName = "Song2.mp3" },
new Song() { FileName = "Song3.mp3" },
new Song() { FileName = "Song4.mp3" },
new Song() { FileName = "Song5.mp3" },
new Song() { FileName = "Song2.mp3" },
new Song() { FileName = "Song3.mp3" },
];
playlist.AddSongs(songs);
playlist.RemoveDuplicateSongs();
string[] expectedFileNames =
[
"Song1.mp3",
"Song2.mp3",
"Song3.mp3",
"Song4.mp3",
"Song5.mp3"
];
playlist.Songs.Select(x => x.Song.FileName).ToArray()
.ShouldBeEquivalentTo(expectedFileNames);
}
[Fact] [Fact]
public void Sort_Songs() public void Sort_Songs()
{ {
@@ -74,6 +164,34 @@ public class PlaylistTests
.ShouldBeEquivalentTo(expectedSortedFileNames); .ShouldBeEquivalentTo(expectedSortedFileNames);
} }
[Fact]
public void Reverse_Songs()
{
Playlist playlist = new();
Song[] songs =
[
new Song() { FileName = "Song1.mp3" },
new Song() { FileName = "Song2.mp3" },
new Song() { FileName = "Song3.mp3" }
];
playlist.AddSongs(songs);
PlaylistSong[] playlistSongs = [.. playlist.Songs];
playlist.Reverse(playlistSongs);
string[] expectedReversedFileNames =
[
"Song3.mp3",
"Song2.mp3",
"Song1.mp3"
];
playlist.Songs.Select(x => x.Song.FileName).ToArray()
.ShouldBeEquivalentTo(expectedReversedFileNames);
}
[Fact] [Fact]
public void Remove_Songs() public void Remove_Songs()
{ {
@@ -101,7 +219,7 @@ public class PlaylistTests
} }
[Fact] [Fact]
public void Lock_Playlist() public void Lock_And_Unlock_Playlist()
{ {
Playlist playlist = new(); Playlist playlist = new();
@@ -114,7 +232,11 @@ public class PlaylistTests
playlist.AddSongs(songs); playlist.AddSongs(songs);
playlist.IsLocked = true; playlist.IsLocked.ShouldBeFalse();
playlist.Lock();
playlist.IsLocked.ShouldBeTrue();
Song song = new() { FileName = "Song4.mp3" }; Song song = new() { FileName = "Song4.mp3" };
playlist.AddSong(song); playlist.AddSong(song);
@@ -124,11 +246,21 @@ public class PlaylistTests
playlist.RemoveSong(0); playlist.RemoveSong(0);
playlist.Songs.Count.ShouldBe(3); playlist.Songs.Count.ShouldBe(3);
}
//public void Get_Playlists() playlist.MoveSong(0, 2);
//{
// //PlaylistRepository playlistRepository = new(); string[] expectedMovedFileNamesOnLockedPlaylist =
// //playlistRepository.Get().Returns() [
//} "Song1.mp3",
"Song2.mp3",
"Song3.mp3"
];
playlist.Songs.Select(x => x.Song.FileName).ToArray()
.ShouldBeEquivalentTo(expectedMovedFileNamesOnLockedPlaylist);
playlist.Unlock();
playlist.IsLocked.ShouldBeFalse();
}
} }

View File

@@ -10,7 +10,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" /> <PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -11,16 +11,16 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.0" /> <PackageReference Include="Avalonia" Version="12.1.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.2" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" /> <PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.2" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.--> <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Egolds.Xaml.Behaviors.Interactions.Animated" Version="11.3.3" /> <PackageReference Include="Egolds.Xaml.Behaviors.Interactions.Animated" Version="11.4.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageReference Include="Semi.Avalonia" Version="12.1.0" /> <PackageReference Include="Semi.Avalonia" Version="12.1.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -433,7 +433,7 @@ public class PlaylistViewModel : ViewModelBase
if (Playlist == null || SelectedPlaylistSongs.Count == 0) if (Playlist == null || SelectedPlaylistSongs.Count == 0)
return; return;
int selectedPlaylistSongIndex = Playlist.Songs.IndexOf(SelectedPlaylistSongs[0]); int selectedPlaylistSongIndex = Playlist.IndexOf(SelectedPlaylistSongs[0]);
if (selectedPlaylistSongIndex == -1) if (selectedPlaylistSongIndex == -1)
return; return;

View File

@@ -1,6 +1,7 @@
using Harmonia.Core.Extensions; using Harmonia.Core.Extensions;
using Harmonia.Core.Playlists;
using Harmonia.WinUI.Caching; using Harmonia.WinUI.Caching;
using Harmonia.WinUI.Messaging;
using Harmonia.WinUI.Session;
using Harmonia.WinUI.Storage; using Harmonia.WinUI.Storage;
using Harmonia.WinUI.ViewModels; using Harmonia.WinUI.ViewModels;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -24,10 +25,20 @@ public partial class App : Application
//services.AddSingleton<MainViewModel>(); //services.AddSingleton<MainViewModel>();
services.AddSingleton<PlayerViewModel>(); services.AddSingleton<PlayerViewModel>();
services.AddSingleton<PlayingSongViewModel>(); services.AddSingleton<PlayingSongViewModel>();
services.AddSingleton<PlaylistViewModel>(); services.AddSingleton<PlaylistsViewModel>();
services.AddSingleton<PlaylistDetailViewModel>();
services.AddSingleton<IAudioBitmapImageCache, AudioBitmapImageCache>(); services.AddSingleton<IAudioBitmapImageCache, AudioBitmapImageCache>();
services.AddSingleton<IStorageProvider, WindowsStorageProvider>(); services.AddSingleton<IStorageProvider, WindowsStorageProvider>();
services.AddSingleton<IMessageService, WindowsMessageService>();
services.AddSingleton<ISessionService, JsonSessionService>();
services.AddSingleton(sp => sp.GetRequiredService<ISessionService>().Session.Window);
services.AddSingleton(sp => sp.GetRequiredService<ISessionService>().Session.Player);
services.AddSingleton(sp => sp.GetRequiredService<ISessionService>().Session.Playlist);
services.AddSingleton<ISessionRestorer, SessionRestorer>();
services.AddSingleton<ISessionTracker, SessionTracker>();
services.AddHarmonia(); services.AddHarmonia();
@@ -43,6 +54,15 @@ public partial class App : Application
{ {
await ServiceProvider.InitializeHarmoniaAsync(); await ServiceProvider.InitializeHarmoniaAsync();
ISessionService sessionService = ServiceProvider.GetRequiredService<ISessionService>();
await sessionService.LoadAsync();
ISessionRestorer sessionRestorer = ServiceProvider.GetRequiredService<ISessionRestorer>();
await sessionRestorer.RestoreAsync();
ISessionTracker sessionTracker = ServiceProvider.GetRequiredService<ISessionTracker>();
sessionTracker.Start();
_mainWindow = ServiceProvider.GetRequiredService<MainWindow>(); _mainWindow = ServiceProvider.GetRequiredService<MainWindow>();
_mainWindow.Activate(); _mainWindow.Activate();
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -40,7 +40,9 @@ public class AudioBitmapImageCache(IAudioImageExtractor audioImageExtractor) : M
protected override async ValueTask<BitmapImage?> FetchAsync(Song key, CancellationToken cancellationToken) protected override async ValueTask<BitmapImage?> FetchAsync(Song key, CancellationToken cancellationToken)
{ {
SongPictureInfo? songPictureInfo = await audioImageExtractor.ExtractImageAsync(key.FileName, cancellationToken); SongPictureInfo? songPictureInfo = await Task.Run(
() => audioImageExtractor.ExtractImageAsync(key.FileName, cancellationToken),
cancellationToken);
if (songPictureInfo == null) if (songPictureInfo == null)
return GetDefaultBitmapImage(); return GetDefaultBitmapImage();
@@ -51,8 +53,17 @@ public class AudioBitmapImageCache(IAudioImageExtractor audioImageExtractor) : M
await bitmapImage.SetSourceAsync(stream.AsRandomAccessStream()); await bitmapImage.SetSourceAsync(stream.AsRandomAccessStream());
bitmapImage.DecodePixelWidth = GetDecodePixelWidth(bitmapImage); int decodePixelWidth = GetDecodePixelWidth(bitmapImage);
bitmapImage.DecodePixelHeight = GetDecodePixelHeight(bitmapImage); int decodePixelHeight = GetDecodePixelHeight(bitmapImage);
if (decodePixelWidth > 0 || decodePixelHeight > 0)
{
bitmapImage.DecodePixelWidth = decodePixelWidth;
bitmapImage.DecodePixelHeight = decodePixelHeight;
stream.Seek(0, SeekOrigin.Begin);
await bitmapImage.SetSourceAsync(stream.AsRandomAccessStream());
}
return bitmapImage; return bitmapImage;
} }
@@ -62,8 +73,6 @@ public class AudioBitmapImageCache(IAudioImageExtractor audioImageExtractor) : M
Uri uri = new("ms-appx:///Assets/Default.png", UriKind.Absolute); Uri uri = new("ms-appx:///Assets/Default.png", UriKind.Absolute);
BitmapImage bitmapImage = new(); BitmapImage bitmapImage = new();
bitmapImage.DecodePixelWidth = GetDecodePixelWidth(bitmapImage);
bitmapImage.DecodePixelHeight = GetDecodePixelHeight(bitmapImage);
bitmapImage.UriSource = uri; bitmapImage.UriSource = uri;
return bitmapImage; return bitmapImage;
@@ -99,7 +108,10 @@ public class AudioBitmapImageCache(IAudioImageExtractor audioImageExtractor) : M
protected override long GetEntrySize(BitmapImage entry) protected override long GetEntrySize(BitmapImage entry)
{ {
return entry.DecodePixelWidth * entry.DecodePixelHeight; int width = entry.DecodePixelWidth > 0 ? entry.DecodePixelWidth : entry.PixelWidth;
int height = entry.DecodePixelHeight > 0 ? entry.DecodePixelHeight : entry.PixelHeight;
return (long)width * height * 4;
} }
} }

View File

@@ -0,0 +1,38 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
using System;
using System.Collections.Generic;
using System.Text;
namespace Harmonia.WinUI.Converters;
public class BooleanToVisibilityConverter : IValueConverter
{
public Visibility TrueValue { get; set; }
public Visibility FalseValue { get; set; }
public BooleanToVisibilityConverter()
{
TrueValue = Visibility.Visible;
FalseValue = Visibility.Collapsed;
}
public object? Convert(object value, Type targetType, object parameter, string language)
{
if (!(value is bool))
return null;
return (bool)value ? TrueValue : FalseValue;
}
public object? ConvertBack(object value, Type targetType, object parameter, string language)
{
if (Equals(value, TrueValue))
return true;
if (Equals(value, FalseValue))
return false;
return null;
}
}

View File

@@ -1,9 +1,44 @@
using System; using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Markup;
using Microsoft.UI.Xaml.Media;
using Harmonia.WinUI.Models; using Harmonia.WinUI.Models;
namespace Harmonia.WinUI.Converters; namespace Harmonia.WinUI.Converters;
public sealed partial class VolumeStateToIconConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, string language)
{
if (value is not VolumeState volumeState)
return null;
if (Application.Current.Resources[$"Volume{volumeState}Icon2"] is not string pathData)
return null;
return XamlBindingHelper.ConvertValue(typeof(Geometry), pathData.Trim());
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}
}
public sealed partial class VolumeStateToFillConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, string language)
{
return value is VolumeState.Muted ? parameter : null;
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}
}
public sealed partial class VolumeStateConverter : IValueConverter public sealed partial class VolumeStateConverter : IValueConverter
{ {
public VolumeStateConverter() public VolumeStateConverter()

View File

@@ -14,9 +14,11 @@
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<None Remove="Resources\Styles.xaml" /> <None Remove="Resources\Styles.xaml" />
<None Remove="Views\PlayerView.xaml" /> <None Remove="Views\PlayerView.xaml" />
<None Remove="Views\PlaylistsView.xaml" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -29,6 +31,10 @@
<Content Include="Assets\Wide310x150Logo.scale-200.png" /> <Content Include="Assets\Wide310x150Logo.scale-200.png" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Include="Assets\Fonts\*.ttf" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Manifest Include="$(ApplicationManifest)" /> <Manifest Include="$(ApplicationManifest)" />
</ItemGroup> </ItemGroup>
@@ -44,8 +50,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="CommunityToolkit.WinUI.Media" Version="8.2.251219" /> <PackageReference Include="CommunityToolkit.WinUI.Media" Version="8.2.251219" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2270" /> <PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.3.1" /> <PackageReference Include="Microsoft.WindowsAppSDK" Version="2.4.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Harmonia.Core\Harmonia.Core.csproj" /> <ProjectReference Include="..\Harmonia.Core\Harmonia.Core.csproj" />
@@ -55,6 +61,11 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Page Update="Views\PlaylistsView.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup> <ItemGroup>
<Page Update="Resources\Geometry.xaml"> <Page Update="Resources\Geometry.xaml">
<SubType>Designer</SubType> <SubType>Designer</SubType>
@@ -68,7 +79,7 @@
<Page Update="Views\PlayingSongView.xaml"> <Page Update="Views\PlayingSongView.xaml">
<SubType>Designer</SubType> <SubType>Designer</SubType>
</Page> </Page>
<Page Update="Views\PlaylistView.xaml"> <Page Update="Views\PlaylistDetailView.xaml">
<SubType>Designer</SubType> <SubType>Designer</SubType>
</Page> </Page>
<Page Update="Views\PlayerView.xaml"> <Page Update="Views\PlayerView.xaml">
@@ -96,5 +107,6 @@
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun> <PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
<PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed> <PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed>
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed> <PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -9,17 +9,38 @@
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"
mc:Ignorable="d" mc:Ignorable="d"
Title="Harmonia.WinUI"> Title="Harmonia.WinUI"
Closed="OnMainWindowClosed">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<!--<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center"> <!--<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<Button x:Name="myButton" Click="myButton_Click">Click Me</Button> <Button x:Name="myButton" Click="myButton_Click">Click Me</Button>
</StackPanel>--> </StackPanel>-->
<Grid> <Grid>
<Grid.Resources>
<LinearGradientBrush x:Key="DarkTitleBarBackgroundBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#0C0E12" Offset="0"/>
<GradientStop Color="#0A0B0E" Offset="1"/>
</LinearGradientBrush>
</Grid.Resources>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition> <RowDefinition Height="*"></RowDefinition>
<RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="Auto"></RowDefinition>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid Grid.RowSpan="2"> <!-- Title Bar -->
<Grid x:Name="AppTitleBar" Grid.Row="0" VerticalAlignment="Center" Padding="10" Background="{StaticResource DarkTitleBarBackgroundBrush}" BorderBrush="#3B332B" BorderThickness="0 0 0 1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"></ColumnDefinition>
<ColumnDefinition Width="*"></ColumnDefinition>
</Grid.ColumnDefinitions>
<!--<Image Grid.Column="0" x:Name="TitleBarIcon" Source="ms-appx:///Assets/Images/MangaReader.png" Width="20" Height="20" Margin="0 0 10 0" />-->
<TextBlock Grid.Column="1" x:Name="AppTitle" Text="{x:Bind Title, Mode=OneWay}" Style="{StaticResource CaptionTextBlockStyle}" VerticalAlignment="Center" />
</Grid>
<!--<Grid Grid.Row="1" Grid.RowSpan="2">
<Image Source="/Assets/Default.png" Stretch="UniformToFill" VerticalAlignment="Center" HorizontalAlignment="Center"></Image> <Image Source="/Assets/Default.png" Stretch="UniformToFill" VerticalAlignment="Center" HorizontalAlignment="Center"></Image>
<Canvas Background="#99000000"></Canvas> <Canvas Background="#99000000"></Canvas>
<Border> <Border>
@@ -27,16 +48,18 @@
<Media:BackdropBlurBrush Amount="20"></Media:BackdropBlurBrush> <Media:BackdropBlurBrush Amount="20"></Media:BackdropBlurBrush>
</Border.Background> </Border.Background>
</Border> </Border>
</Grid> </Grid>-->
<Grid Grid.Row="0"> <Grid Grid.Row="1">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition></ColumnDefinition> <ColumnDefinition></ColumnDefinition>
<ColumnDefinition></ColumnDefinition> <ColumnDefinition></ColumnDefinition>
<ColumnDefinition></ColumnDefinition>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<views:PlayingSongView Grid.Column="0"></views:PlayingSongView> <views:PlayingSongView Grid.Column="0"></views:PlayingSongView>
<views:PlaylistView Grid.Column="1"></views:PlaylistView> <views:PlaylistsView Grid.Column="1"></views:PlaylistsView>
<views:PlaylistDetailView Grid.Column="2"></views:PlaylistDetailView>
</Grid> </Grid>
<views:PlayerView Grid.Row="1"></views:PlayerView> <views:PlayerView Grid.Row="2"></views:PlayerView>
</Grid> </Grid>
</Window> </Window>

View File

@@ -1,11 +1,129 @@
using Harmonia.Core.Player;
using Harmonia.WinUI.Session;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using System;
using System.Runtime.InteropServices;
namespace Harmonia.WinUI; namespace Harmonia.WinUI;
public sealed partial class MainWindow : Window public sealed partial class MainWindow : Window
{ {
public MainWindow() private const string ApplicationTitle = "Harmonia";
[LibraryImport("kernel32.dll", SetLastError = true)]
private static partial EXECUTION_STATE SetThreadExecutionState(EXECUTION_STATE esFlags);
private readonly ISessionService _sessionService;
private readonly ISessionTracker _sessionTracker;
private readonly IAudioPlayer _audioPlayer;
private bool _isSaved;
public MainWindow(ISessionService sessionService, ISessionTracker sessionTracker, IAudioPlayer audioPlayer)
{ {
_sessionService = sessionService;
_sessionTracker = sessionTracker;
_audioPlayer = audioPlayer;
_audioPlayer.PropertyChanged += OnAudioPlayerPropertyChanged;
InitializeComponent(); InitializeComponent();
InitializeTitleBar();
InitializeWindowState();
}
private void InitializeTitleBar()
{
Title = ApplicationTitle;
ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar);
}
private void InitializeWindowState()
{
if (AppWindow.Presenter is OverlappedPresenter presenter && _sessionService.Session.Window.IsMaximized)
{
presenter.Maximize();
}
AppWindow.Changed += OnAppWindowChanged;
}
private void OnAppWindowChanged(AppWindow sender, AppWindowChangedEventArgs args)
{
if (sender.Presenter is not OverlappedPresenter presenter)
return;
// Ignore the minimized state so a minimize-then-close doesn't lose the maximized flag.
if (presenter.State is OverlappedPresenterState.Minimized)
return;
_sessionService.Session.Window.IsMaximized = presenter.State == OverlappedPresenterState.Maximized;
}
private async void OnMainWindowClosed(object sender, WindowEventArgs args)
{
EnableSleepIdleTimeout();
if (_isSaved)
return;
args.Handled = true;
_sessionTracker.Capture();
await _sessionService.SaveAsync();
_isSaved = true;
Close();
}
private void OnAudioPlayerPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(_audioPlayer.State):
OnAudioPlayerStateChanged(sender, e);
break;
}
}
private void OnAudioPlayerStateChanged(object? sender, EventArgs e)
{
// SetThreadExecutionState is per-thread, so always marshal to the UI thread to
// guarantee the ES_CONTINUOUS flags are set and cleared on the same thread.
DispatcherQueue.TryEnqueue(() =>
{
switch (_audioPlayer.State)
{
case Core.Engine.AudioPlaybackState.Playing:
DisableSleepIdleTimeout();
break;
default:
EnableSleepIdleTimeout();
break;
}
});
}
private static void EnableSleepIdleTimeout()
{
// Clear EXECUTION_STATE flags to allow the system to idle to sleep normally.
SetThreadExecutionState(EXECUTION_STATE.ES_CONTINUOUS);
}
private static void DisableSleepIdleTimeout()
{
// Prevent the system sleep idle time-out while still allowing the display to sleep.
SetThreadExecutionState(EXECUTION_STATE.ES_CONTINUOUS | EXECUTION_STATE.ES_SYSTEM_REQUIRED);
}
[Flags]
private enum EXECUTION_STATE : uint
{
ES_CONTINUOUS = 0x80000000,
ES_SYSTEM_REQUIRED = 0x00000001,
ES_DISPLAY_REQUIRED = 0x00000002,
ES_AWAYMODE_REQUIRED = 0x00000040
} }
} }

View File

@@ -0,0 +1,9 @@
using System.Threading.Tasks;
namespace Harmonia.WinUI.Messaging;
public interface IMessageService
{
Task<bool> ConfirmAsync(string title, string message);
Task<string> InputTextAsync(string title, string message, string defaultText = "");
}

View File

@@ -0,0 +1,70 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
using System.Threading.Tasks;
namespace Harmonia.WinUI.Messaging;
public class WindowsMessageService : IMessageService
{
private static MainWindow MainWindow => App.ServiceProvider.GetRequiredService<MainWindow>();
public async Task<bool> ConfirmAsync(string title, string message)
{
ContentDialog dialog = new()
{
XamlRoot = MainWindow.Content.XamlRoot,
Style = Application.Current.Resources["DefaultContentDialogStyle"] as Style,
Title = title,
Content = message,
PrimaryButtonText = "Yes",
CloseButtonText = "No",
DefaultButton = ContentDialogButton.Primary
};
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
public async Task<string> InputTextAsync(string title, string message, string defaultText = "")
{
TextBox inputTextBox = new()
{
Text = defaultText,
Margin = new Thickness(0, 10, 0, 0)
};
inputTextBox.SelectAll();
ContentDialog dialog = new()
{
XamlRoot = MainWindow.Content.XamlRoot,
Style = Application.Current.Resources["DefaultContentDialogStyle"] as Style,
Title = title,
Content = new StackPanel
{
Children =
{
new TextBlock { Text = message },
inputTextBox
}
},
PrimaryButtonText = "OK",
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Primary
};
ContentDialogResult result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
return inputTextBox.Text;
}
else
{
return string.Empty;
}
}
}

View File

@@ -11,5 +11,7 @@
<converters:DoubleToPercentConverter x:Key="DoubleToPercent" /> <converters:DoubleToPercentConverter x:Key="DoubleToPercent" />
<converters:RepeatStateConverter x:Key="RepeatState" /> <converters:RepeatStateConverter x:Key="RepeatState" />
<converters:VolumeStateConverter x:Key="VolumeState" /> <converters:VolumeStateConverter x:Key="VolumeState" />
<converters:VolumeStateToIconConverter x:Key="VolumeStateToIcon" />
<converters:VolumeStateToFillConverter x:Key="VolumeStateToFill" />
</ResourceDictionary> </ResourceDictionary>

View File

@@ -11,6 +11,14 @@
m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.39 m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.39
</x:String> </x:String>
<x:String x:Key="PlayFillIcon2">
m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393
</x:String>
<x:String x:Key="PlayFillIcon3">
M440.415,583.554 L421.418,571.311 C420.291,570.704 419,570.767 419,572.946 L419,597.054 C419,599.046 420.385,599.36 421.418,598.689 L440.415,586.446 C441.197,585.647 441.197,584.353 440.415,583.554
</x:String>
<x:String x:Key="StopIcon"> <x:String x:Key="StopIcon">
M3.5 5A1.5 1.5 0 0 1 5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11zM5 4.5a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5z M3.5 5A1.5 1.5 0 0 1 5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11zM5 4.5a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5z
</x:String> </x:String>
@@ -71,6 +79,37 @@
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 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
</x:String> </x:String>
<x:String x:Key="VolumeHighIcon2">
M126,192H56a8,8,0,0,0-8,8V312a8,8,0,0,0,8,8h69.65a15.93,15.93,0,0,1,10.14,3.54l91.47,74.89A8,8,0,0,0,240,392V120a8,8,0,0,0-12.74-6.43l-91.47,74.89A15,15,0,0,1,126,192Z
M320,320c9.74-19.38,16-40.84,16-64,0-23.48-6-44.42-16-64
M368,368c19.48-33.92,32-64.06,32-112s-12-77.74-32-112
M416,416c30-46,48-91.43,48-160S446,143,416,96
</x:String>
<x:String x:Key="VolumeMediumIcon2">
M157.65,192H88a8,8,0,0,0-8,8V312a8,8,0,0,0,8,8h69.65a16,16,0,0,1,10.14,3.63l91.47,75A8,8,0,0,0,272,392.17V119.83a8,8,0,0,0-12.74-6.44l-91.47,75A16,16,0,0,1,157.65,192Z
M352,320c9.74-19.41,16-40.81,16-64,0-23.51-6-44.4-16-64
M400,368c19.48-34,32-64,32-112s-12-77.7-32-112
</x:String>
<x:String x:Key="VolumeLowIcon2">
M189.65,192H120a8,8,0,0,0-8,8V312a8,8,0,0,0,8,8h69.65a16,16,0,0,1,10.14,3.63l91.47,75A8,8,0,0,0,304,392.17V119.83a8,8,0,0,0-12.74-6.44l-91.47,75A16,16,0,0,1,189.65,192Z
M384,320c9.74-19.41,16-40.81,16-64,0-23.51-6-44.4-16-64
</x:String>
<x:String x:Key="VolumeOffIcon2">
M237.65,192H168a8,8,0,0,0-8,8V312a8,8,0,0,0,8,8h69.65a16,16,0,0,1,10.14,3.63l91.47,75A8,8,0,0,0,352,392.17V119.83a8,8,0,0,0-12.74-6.44l-91.47,75A16,16,0,0,1,237.65,192Z
</x:String>
<x:String x:Key="VolumeMutedIcon2">
M416,432 64,80
M224,136.92v33.8a4,4,0,0,0,1.17,2.82l24,24a4,4,0,0,0,6.83-2.82V120.57a24.53,24.53,0,0,0-12.67-21.72,23.91,23.91,0,0,0-25.55,1.83,8.27,8.27,0,0,0-.66.51l-31.94,26.15a4,4,0,0,0-.29,5.92l17.05,17.06a4,4,0,0,0,5.37.26Z
M224,375.08l-78.07-63.92A32,32,0,0,0,125.65,304H64V208h50.72a4,4,0,0,0,2.82-6.83l-24-24A4,4,0,0,0,90.72,176H56a24,24,0,0,0-24,24V312a24,24,0,0,0,24,24h69.76l91.36,74.8a8.27,8.27,0,0,0,.66.51A23.93,23.93,0,0,0,243.63,413,24.49,24.49,0,0,0,256,391.45V341.28a4,4,0,0,0-1.17-2.82l-24-24a4,4,0,0,0-6.83,2.82ZM125.82,336Z
M352,256c0-24.56-5.81-47.88-17.75-71.27a16,16,0,0,0-28.5,14.54C315.34,218.06,320,236.62,320,256q0,4-.31,8.13a8,8,0,0,0,2.32,6.25l19.66,19.67a4,4,0,0,0,6.75-2A146.89,146.89,0,0,0,352,256Z
M416,256c0-51.19-13.08-83.89-34.18-120.06a16,16,0,0,0-27.64,16.12C373.07,184.44,384,211.83,384,256c0,23.83-3.29,42.88-9.37,60.65a8,8,0,0,0,1.9,8.26l16.77,16.76a4,4,0,0,0,6.52-1.27C410.09,315.88,416,289.91,416,256Z
M480,256c0-74.26-20.19-121.11-50.51-168.61a16,16,0,1,0-27,17.22C429.82,147.38,448,189.5,448,256c0,47.45-8.9,82.12-23.59,113a4,4,0,0,0,.77,4.55L443,391.39a4,4,0,0,0,6.4-1C470.88,348.22,480,307,480,256Z
</x:String>
<x:String x:Key="ShuffleIcon"> <x:String 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 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 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
@@ -141,5 +180,15 @@
M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3 M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3
</x:String> </x:String>
<x:String x:Key="MusicNoteList">
M12 13c0 1.105-1.12 2-2.5 2S7 14.105 7 13s1.12-2 2.5-2 2.5.895 2.5 2
M12 3v10h-1V3z
M11 2.82a1 1 0 0 1 .804-.98l3-.6A1 1 0 0 1 16 2.22V4l-5 1z
M0 11.5a.5.5 0 0 1 .5-.5H4a.5.5 0 0 1 0 1H.5a.5.5 0 0 1-.5-.5m0-4A.5.5 0 0 1 .5 7H8a.5.5 0 0 1 0 1H.5a.5.5 0 0 1-.5-.5m0-4A.5.5 0 0 1 .5 3H8a.5.5 0 0 1 0 1H.5a.5.5 0 0 1-.5-.5
</x:String>
<x:String x:Key="SortIcon">
M7 5C7.55228 5 8 5.44772 8 6V15.5858L10.2929 13.2929C10.6834 12.9024 11.3166 12.9024 11.7071 13.2929C12.0976 13.6834 12.0976 14.3166 11.7071 14.7071L7.70711 18.7071C7.31658 19.0976 6.68342 19.0976 6.29289 18.7071L2.29289 14.7071C1.90237 14.3166 1.90237 13.6834 2.29289 13.2929C2.68342 12.9024 3.31658 12.9024 3.70711 13.2929L6 15.5858V6C6 5.44772 6.44772 5 7 5ZM16.2929 5.29289C16.6834 4.90237 17.3166 4.90237 17.7071 5.29289L21.7071 9.29289C22.0976 9.68342 22.0976 10.3166 21.7071 10.7071C21.3166 11.0976 20.6834 11.0976 20.2929 10.7071L18 8.41421V18C18 18.5523 17.5523 19 17 19C16.4477 19 16 18.5523 16 18V8.41421L13.7071 10.7071C13.3166 11.0976 12.6834 11.0976 12.2929 10.7071C11.9024 10.3166 11.9024 9.68342 12.2929 9.29289L16.2929 5.29289Z
</x:String>
</ResourceDictionary> </ResourceDictionary>

View File

@@ -4,20 +4,167 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Global Font --> <!-- Global Font -->
<FontFamily x:Key="AppFontFamily">ms-appx:///Assets/Fonts/Lexend-Regular.ttf#Lexend, ms-appx:///Assets/Fonts/NotoSansJP-Regular.ttf#Noto Sans JP</FontFamily>
<FontFamily x:Key="AppFontFamilyMedium">ms-appx:///Assets/Fonts/Lexend-Medium.ttf#Lexend, ms-appx:///Assets/Fonts/NotoSansJP-Medium.ttf#Noto Sans JP</FontFamily>
<FontFamily x:Key="AppFontFamilySemiBold">ms-appx:///Assets/Fonts/Lexend-SemiBold.ttf#Lexend, ms-appx:///Assets/Fonts/NotoSansJP-SemiBold.ttf#Noto Sans JP</FontFamily>
<FontFamily x:Key="AppFontFamilyBold">ms-appx:///Assets/Fonts/Lexend-Bold.ttf#Lexend, ms-appx:///Assets/Fonts/NotoSansJP-Bold.ttf#Noto Sans JP</FontFamily>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="FontFamily" Value="Lexend,Noto Sans JP" /> <Setter Property="FontFamily" Value="{StaticResource AppFontFamily}" />
</Style> </Style>
<Style TargetType="TextBox" BasedOn="{StaticResource DefaultTextBoxStyle}"> <Style TargetType="TextBox" BasedOn="{StaticResource DefaultTextBoxStyle}">
<Setter Property="FontFamily" Value="Lexend,Noto Sans JP" /> <Setter Property="FontFamily" Value="{StaticResource AppFontFamily}" />
</Style> </Style>
<Style TargetType="MenuFlyoutItem" BasedOn="{StaticResource DefaultMenuFlyoutItemStyle}"> <Style TargetType="MenuFlyoutItem" BasedOn="{StaticResource DefaultMenuFlyoutItemStyle}">
<Setter Property="FontFamily" Value="Lexend,Noto Sans JP" /> <Setter Property="FontFamily" Value="{StaticResource AppFontFamily}" />
</Style> </Style>
<Style TargetType="MenuFlyoutSubItem" BasedOn="{StaticResource DefaultMenuFlyoutSubItemStyle}">
<Setter Property="FontFamily" Value="{StaticResource AppFontFamily}" />
</Style>
<Style TargetType="ToggleMenuFlyoutItem" BasedOn="{StaticResource DefaultToggleMenuFlyoutItemStyle}">
<Setter Property="FontFamily" Value="{StaticResource AppFontFamily}" />
</Style>
<Style TargetType="ListViewItem" BasedOn="{StaticResource DefaultListViewItemStyle}"> <Style TargetType="ListViewItem" BasedOn="{StaticResource DefaultListViewItemStyle}">
<Setter Property="FontFamily" Value="Lexend,Noto Sans JP" /> <Setter Property="FontFamily" Value="{StaticResource AppFontFamily}" />
</Style>
<!-- Premium Palette (from Harmonia icon set) -->
<LinearGradientBrush x:Key="PremiumGoldGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#E9CFA3" Offset="0"/>
<GradientStop Color="#C7A468" Offset="0.5"/>
<GradientStop Color="#8A6D42" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="PremiumTileGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#2B2830" Offset="0"/>
<GradientStop Color="#1E1B22" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PremiumForegroundBrush" Color="#E9CFA3"/>
<!-- Accent: Teal (Shuffle / Repeat) -->
<LinearGradientBrush x:Key="PremiumTealGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#A8DFD3" Offset="0"/>
<GradientStop Color="#6FBFAE" Offset="0.5"/>
<GradientStop Color="#3E7A6C" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="PremiumTealTileGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#26302D" Offset="0"/>
<GradientStop Color="#1A211F" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PremiumTealForegroundBrush" Color="#A8DFD3"/>
<!-- Accent: Lavender (Previous / Next) -->
<LinearGradientBrush x:Key="PremiumLavenderGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#CBB8EF" Offset="0"/>
<GradientStop Color="#A78FD6" Offset="0.5"/>
<GradientStop Color="#6C5799" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="PremiumLavenderTileGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#2A2733" Offset="0"/>
<GradientStop Color="#1D1B24" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PremiumLavenderForegroundBrush" Color="#CBB8EF"/>
<!-- Accent: Pink (Radio / Podcasts) -->
<LinearGradientBrush x:Key="PremiumPinkGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#EFB8C8" Offset="0"/>
<GradientStop Color="#D68FA8" Offset="0.5"/>
<GradientStop Color="#99576E" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="PremiumPinkTileGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#332730" Offset="0"/>
<GradientStop Color="#241B21" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PremiumPinkForegroundBrush" Color="#EFB8C8"/>
<!-- Premium Button -->
<Style x:Key="PremiumButton" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource PremiumForegroundBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource PremiumGoldGradient}"/>
<Setter Property="Background" Value="{StaticResource PremiumTileGradient}"/>
<Setter Property="Padding" Value="14,8"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="FontFamily" Value="{StaticResource AppFontFamily}"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<!-- Outer gradient acts as the border ring -->
<Grid x:Name="Root"
Background="{TemplateBinding BorderBrush}"
CornerRadius="{TemplateBinding CornerRadius}"
Padding="1.5">
<Grid x:Name="Inner"
Background="{TemplateBinding Background}"
CornerRadius="10">
<ContentPresenter x:Name="ContentPresenter"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Padding="{TemplateBinding Padding}"
Foreground="{TemplateBinding Foreground}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"/>
</Grid>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal"/>
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Target="Inner.Opacity" Value="0.88"/>
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<VisualState.Setters>
<Setter Target="Inner.Opacity" Value="0.75"/>
<Setter Target="Root.Opacity" Value="0.9"/>
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Target="Root.Opacity" Value="0.4"/>
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Premium Button (Teal) -->
<Style x:Key="PremiumButton-Teal" TargetType="Button" BasedOn="{StaticResource PremiumButton}">
<Setter Property="Foreground" Value="{StaticResource PremiumTealForegroundBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource PremiumTealGradient}"/>
<Setter Property="Background" Value="{StaticResource PremiumTealTileGradient}"/>
</Style>
<!-- Premium Button (Lavender) -->
<Style x:Key="PremiumButton-Lavender" TargetType="Button" BasedOn="{StaticResource PremiumButton}">
<Setter Property="Foreground" Value="{StaticResource PremiumLavenderForegroundBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource PremiumLavenderGradient}"/>
<Setter Property="Background" Value="{StaticResource PremiumLavenderTileGradient}"/>
</Style>
<!-- Premium Button (Pink) -->
<Style x:Key="PremiumButton-Pink" TargetType="Button" BasedOn="{StaticResource PremiumButton}">
<Setter Property="Foreground" Value="{StaticResource PremiumPinkForegroundBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource PremiumPinkGradient}"/>
<Setter Property="Background" Value="{StaticResource PremiumPinkTileGradient}"/>
</Style> </Style>
<!-- Flat Button --> <!-- Flat Button -->

View File

@@ -1,11 +1,13 @@
namespace Harmonia.WinUI.Session; using Harmonia.Core.Engine;
namespace Harmonia.WinUI.Session;
public partial class AudioPlayerState public partial class AudioPlayerState
{ {
public string? PlaylistUID { get; set; } public string? PlaylistUID { get; set; }
public string? PlaylistSongUID { get; set; } public string? PlaylistSongUID { get; set; }
public double Position { get; set; } = 0.0; public double Position { get; set; } = 0.0;
public string? PlaybackState { get; set; } public AudioPlaybackState PlaybackState { get; set; } = AudioPlaybackState.Stopped;
public double Volume { get; set; } = 1.0; public double Volume { get; set; } = 1.0;
public bool IsMuted { get; set; } public bool IsMuted { get; set; }
public string? RepeatState { get; set; } public string? RepeatState { get; set; }

View File

@@ -0,0 +1,8 @@
using System.Threading.Tasks;
namespace Harmonia.WinUI.Session;
public interface ISessionRestorer
{
Task RestoreAsync();
}

View File

@@ -0,0 +1,10 @@
using System.Threading.Tasks;
namespace Harmonia.WinUI.Session;
public interface ISessionService
{
ApplicationSession Session { get; }
Task LoadAsync();
Task SaveAsync();
}

View File

@@ -0,0 +1,7 @@
namespace Harmonia.WinUI.Session;
public interface ISessionTracker
{
void Start();
void Capture();
}

View File

@@ -0,0 +1,47 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
namespace Harmonia.WinUI.Session;
public sealed class JsonSessionService : ISessionService
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true
};
private readonly string _filePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Harmonia",
"session.json"
);
public ApplicationSession Session { get; private set; } = new();
public async Task LoadAsync()
{
try
{
if (File.Exists(_filePath))
{
await using var stream = File.OpenRead(_filePath);
Session = await JsonSerializer.DeserializeAsync<ApplicationSession>(stream) ?? new();
}
}
catch
{
Session = new();
}
}
public async Task SaveAsync()
{
string directoryName = Path.GetDirectoryName(_filePath)!;
Directory.CreateDirectory(directoryName);
await using var stream = File.Create(_filePath);
await JsonSerializer.SerializeAsync(stream, Session, Options);
}
}

View File

@@ -0,0 +1,62 @@
using Harmonia.Core.Engine;
using Harmonia.Core.Player;
using Harmonia.Core.Playlists;
using System.Linq;
using System.Threading.Tasks;
namespace Harmonia.WinUI.Session;
public class SessionRestorer(
ISessionService sessionService,
IPlaylistManager playlistManager,
IAudioPlayer audioPlayer) : ISessionRestorer
{
public async Task RestoreAsync()
{
RestorePlaylist();
await RestoreAudioPlayerAsync();
}
private void RestorePlaylist()
{
PlaylistState playlistState = sessionService.Session.Playlist;
if (string.IsNullOrWhiteSpace(playlistState.OpenPlaylistUID))
return;
Playlist? playlist = playlistManager.Playlists.FirstOrDefault(p => p.UID == playlistState.OpenPlaylistUID);
if (playlist is null)
return;
playlistManager.CurrentPlaylist = playlist;
}
private async Task RestoreAudioPlayerAsync()
{
AudioPlayerState audioPlayerState = sessionService.Session.Player;
audioPlayer.Volume = audioPlayerState.Volume;
audioPlayer.IsMuted = audioPlayerState.IsMuted;
if (string.IsNullOrWhiteSpace(audioPlayerState.PlaylistSongUID))
return;
Playlist? playlist = playlistManager.FindPlaylistContaining(audioPlayerState.PlaylistSongUID);
if (playlist is null)
return;
PlaylistSong? playlistSong = playlist.Songs.FirstOrDefault(s => s.UID == audioPlayerState.PlaylistSongUID);
if (playlistSong is null)
return;
await audioPlayer.LoadAsync(playlistSong, PlaybackMode.LoadOnly);
if (audioPlayerState.Position > 0 && audioPlayerState.Position <= playlistSong.Song.Length.TotalSeconds)
audioPlayer.Position = audioPlayerState.Position;
if (audioPlayerState.PlaybackState == AudioPlaybackState.Playing)
audioPlayer.Play();
}
}

View File

@@ -0,0 +1,50 @@
using Harmonia.Core.Engine;
using Harmonia.Core.Player;
using Harmonia.Core.Playlists;
using System;
namespace Harmonia.WinUI.Session;
public class SessionTracker(
ISessionService sessionService,
IPlaylistManager playlistManager,
IAudioPlayer audioPlayer) : ISessionTracker
{
public void Start()
{
playlistManager.CurrentPlaylistChanged += OnCurrentPlaylistChanged;
audioPlayer.PlayingSongChanged += OnPlayingSongChanged;
}
private void OnCurrentPlaylistChanged(object? sender, EventArgs e)
{
PlaylistState playlistState = sessionService.Session.Playlist;
playlistState.OpenPlaylistUID = playlistManager.CurrentPlaylist?.UID;
}
private void OnPlayingSongChanged(object? sender, PlayingSongChangedEventArgs e)
{
AudioPlayerState audioPlayerState = sessionService.Session.Player;
audioPlayerState.PlaylistSongUID = e.NewSong?.UID;
}
public void Capture()
{
AudioPlayerState audioPlayerState = sessionService.Session.Player;
audioPlayerState.Position = audioPlayer.Position;
audioPlayerState.PlaybackState = GetSemanticPlaybackState();
audioPlayerState.Volume = audioPlayer.Volume;
audioPlayerState.IsMuted = audioPlayer.IsMuted;
}
private AudioPlaybackState GetSemanticPlaybackState()
{
if (audioPlayer.PlayingSong is null)
return AudioPlaybackState.Stopped;
return audioPlayer.State == AudioPlaybackState.Playing
? AudioPlaybackState.Playing
: AudioPlaybackState.Paused;
}
}

View File

@@ -225,13 +225,27 @@ public partial class PlayerViewModel : ViewModelBase
_timer.Tick += TickTock; _timer.Tick += TickTock;
_dispatcherQueue = DispatcherQueue.GetForCurrentThread(); _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
TryRestore();
}
private void TryRestore()
{
if (_audioPlayer.PlayingSong is null)
return;
Song = _audioPlayer.PlayingSong.Song;
Position = _audioPlayer.Position;
CurrentPosition = _audioPlayer.Position;
Task.Run(UpdateImage);
} }
#region Event Handlers #region Event Handlers
private void OnPlayingSongChanged(object? sender, EventArgs e) private void OnPlayingSongChanged(object? sender, PlayingSongChangedEventArgs e)
{ {
Song = _audioPlayer.PlayingSong?.Song; Song = e.NewSong?.Song;
Task.Run(UpdateImage); Task.Run(UpdateImage);
} }

View File

@@ -50,11 +50,22 @@ public partial class PlayingSongViewModel : ViewModelBase
_audioBitmapImageCache = audioBitmapImageCache; _audioBitmapImageCache = audioBitmapImageCache;
_dispatcherQueue = DispatcherQueue.GetForCurrentThread(); _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
TryRestore();
} }
private void OnAudioPlayerPlayingSongChanged(object? sender, EventArgs e) private void TryRestore()
{ {
Song = _audioPlayer.PlayingSong?.Song; if (_audioPlayer.PlayingSong is null)
return;
Song = _audioPlayer.PlayingSong.Song;
Task.Run(UpdateImage);
}
private void OnAudioPlayerPlayingSongChanged(object? sender, PlayingSongChangedEventArgs e)
{
Song = e.NewSong?.Song;
Task.Run(UpdateImage); Task.Run(UpdateImage);
} }

View File

@@ -0,0 +1,795 @@
using CommunityToolkit.Mvvm.Input;
using Harmonia.Core.Caching;
using Harmonia.Core.Engine;
using Harmonia.Core.Imaging;
using Harmonia.Core.Models;
using Harmonia.Core.Player;
using Harmonia.Core.Playlists;
using Harmonia.Core.Scanner;
using Harmonia.WinUI.Caching;
using Harmonia.WinUI.Messaging;
using Harmonia.WinUI.Storage;
using Microsoft.UI.Xaml.Media.Imaging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Windows.ApplicationModel.DataTransfer;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
namespace Harmonia.WinUI.ViewModels;
public partial class PlaylistDetailViewModel : ViewModelBase
{
private readonly IPlaylistManager _playlistManager;
private readonly IAudioPlayer _audioPlayer;
private readonly IAudioImageCache _audioImageCache;
private readonly IAudioBitmapImageCache _audioBitmapImageCache;
private readonly IAudioFileScanner _audioFileScanner;
private readonly IAudioEngine _audioEngine;
private readonly IStorageProvider _storageProvider;
private readonly IMessageService _messageService;
private readonly DispatcherQueue _dispatcherQueue;
private readonly ConcurrentDictionary<int, CancellationTokenSource> _imageCancellationTokens = [];
private DispatcherQueueTimer? _filterTimer;
private Playlist? _playlist;
public Playlist? Playlist
{
get => _playlist;
private set
{
_playlist?.PlaylistUpdated -= OnPlaylistUpdated;
SetProperty(ref _playlist, value);
_playlist?.PlaylistUpdated += OnPlaylistUpdated;
IsPlaylistLocked = Playlist?.IsLocked ?? false;
}
}
private PlaylistSong? _playingSong;
public PlaylistSong? PlayingSong
{
get
{
return _playingSong;
}
set
{
SetProperty(ref _playingSong, value);
}
}
private string? _filter;
public string? Filter
{
get
{
return _filter;
}
set
{
SetProperty(ref _filter, value);
OnPropertyChanged(nameof(CanReorderSongs));
RestartFilterTimer();
}
}
public bool CanReorderSongs => string.IsNullOrWhiteSpace(Filter);
private ObservableCollection<PlaylistSong> _filteredPlaylistSongs = [];
public ObservableCollection<PlaylistSong> FilteredPlaylistSongs
{
get
{
return _filteredPlaylistSongs;
}
set
{
SetProperty(ref _filteredPlaylistSongs, value);
}
}
private ObservableCollection<PlaylistSong> _selectedPlaylistSongs = [];
public ObservableCollection<PlaylistSong> SelectedPlaylistSongs
{
get
{
return _selectedPlaylistSongs;
}
set
{
SetProperty(ref _selectedPlaylistSongs, value);
NotifySelectionDependentCommands();
}
}
public SortItem[] SortItems { get; } =
[
new SortItem
{
Name = "Album - Disc # - Track # - Title - File Name",
SortOptions =
[
new(nameof(Song.Album), ListSortDirection.Ascending),
new(nameof(Song.DiscNumber), ListSortDirection.Ascending),
new(nameof(Song.TrackNumber), ListSortDirection.Ascending),
new(nameof(Song.Title), ListSortDirection.Ascending),
new(nameof(Song.FileName), ListSortDirection.Ascending)
]
},
new SortItem
{
Name = "Title - File Name",
SortOptions =
[
new(nameof(Song.Title), ListSortDirection.Ascending),
new(nameof(Song.FileName), ListSortDirection.Ascending)
]
}
];
private bool _isPlaylistLocked;
public bool IsPlaylistLocked
{
get => _isPlaylistLocked;
private set => SetProperty(ref _isPlaylistLocked, value);
}
public IAsyncRelayCommand PlaySongCommand { get; }
public IAsyncRelayCommand NewPlaylistCommand { get; }
public IAsyncRelayCommand AddFilesCommand { get; }
public IAsyncRelayCommand AddFolderCommand { get; }
public IRelayCommand RemoveSongsCommand { get; }
public IRelayCommand CutSongsCommand { get; }
public IRelayCommand CopySongsCommand { get; }
public IAsyncRelayCommand PasteSongsCommand { get; }
public IRelayCommand OpenFileLocationCommand { get; }
public IAsyncRelayCommand RefreshTagsCommand { get; }
public IRelayCommand RemoveMissingSongsCommand { get; }
public IRelayCommand RemoveDuplicateSongsCommand { get; }
public IAsyncRelayCommand DeletePlaylistCommand { get; }
public IRelayCommand<SortItem> SortAllCommand { get; }
public IRelayCommand<SortItem> SortSelectedCommand { get; }
public IRelayCommand RandomizeAllCommand { get; }
public IRelayCommand RandomizeSelectedCommand { get; }
public IRelayCommand ReverseAllCommand { get; }
public IRelayCommand ReverseSelectedCommand { get; }
public IAsyncRelayCommand RenamePlaylistCommand { get; }
public IRelayCommand LockPlaylistCommand { get; }
public IRelayCommand UnlockPlaylistCommand { get; }
public bool IsUserUpdating { get; set; }
private bool _isUserInitiatingSongChange;
private PlaylistSong? _pendingMovedSong;
private int _pendingMovedSongIndex = -1;
public event EventHandler? PlayingSongChangedAutomatically;
public PlaylistDetailViewModel(
IAudioPlayer audioPlayer,
IAudioImageCache audioImageCache,
IAudioBitmapImageCache audioBitmapImageCache,
IAudioFileScanner audioFileScanner,
IAudioEngine audioEngine,
IStorageProvider storageProvider,
IPlaylistManager playlistManager,
IMessageService messageService)
{
_playlistManager = playlistManager;
_playlistManager.CurrentPlaylistChanged += OnPlaylistChanged;
_audioPlayer = audioPlayer;
_audioPlayer.PlaylistChanged += OnPlaylistChanged;
_audioPlayer.PlayingSongChanged += OnPlayingSongChanged;
_audioImageCache = audioImageCache;
_audioBitmapImageCache = audioBitmapImageCache;
_audioFileScanner = audioFileScanner;
_audioEngine = audioEngine;
_storageProvider = storageProvider;
_messageService = messageService;
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
PlaySongCommand = new AsyncRelayCommand(PlaySongAsync, AreSongsSelected);
NewPlaylistCommand = new AsyncRelayCommand(NewPlaylistAsync);
AddFilesCommand = new AsyncRelayCommand(AddFilesAsync);
AddFolderCommand = new AsyncRelayCommand(AddFolderAsync);
RemoveSongsCommand = new RelayCommand(RemoveSongs, AreSongsSelected);
CutSongsCommand = new RelayCommand(CutSongs, AreSongsSelected);
CopySongsCommand = new RelayCommand(CopySongs, AreSongsSelected);
PasteSongsCommand = new AsyncRelayCommand(PasteSongsAsync, CanPasteSongs);
OpenFileLocationCommand = new RelayCommand(OpenFileLocation, AreSongsSelected);
RefreshTagsCommand = new AsyncRelayCommand(RefreshTagsAsync);
RemoveMissingSongsCommand = new RelayCommand(RemoveMissingSongs);
RemoveDuplicateSongsCommand = new RelayCommand(RemoveDuplicateSongs);
DeletePlaylistCommand = new AsyncRelayCommand(DeletePlaylistAsync);
SortAllCommand = new RelayCommand<SortItem>(SortAllSongs);
SortSelectedCommand = new RelayCommand<SortItem>(SortSelectedSongs, _ => AreMultipleSongsSelected());
RandomizeAllCommand = new RelayCommand(RandomizeAllSongs);
RandomizeSelectedCommand = new RelayCommand(RandomizeSelectedSongs, AreMultipleSongsSelected);
ReverseAllCommand = new RelayCommand(ReverseAllSongs);
ReverseSelectedCommand = new RelayCommand(ReverseSelectedSongs, AreMultipleSongsSelected);
RenamePlaylistCommand = new AsyncRelayCommand(RenamePlaylist);
LockPlaylistCommand = new RelayCommand(LockPlaylist);
UnlockPlaylistCommand = new RelayCommand(UnlockPlaylist);
FilteredPlaylistSongs.CollectionChanged += OnFilteredPlaylistSongsCollectionChanged;
Playlist = _playlistManager.CurrentPlaylist; // Testing
PlayingSong = _audioPlayer.PlayingSong; // Testing
UpdateFilteredSongs();
}
private void NotifySelectionDependentCommands()
{
PlaySongCommand.NotifyCanExecuteChanged();
RemoveSongsCommand.NotifyCanExecuteChanged();
CutSongsCommand.NotifyCanExecuteChanged();
CopySongsCommand.NotifyCanExecuteChanged();
PasteSongsCommand.NotifyCanExecuteChanged();
OpenFileLocationCommand.NotifyCanExecuteChanged();
SortSelectedCommand.NotifyCanExecuteChanged();
RandomizeSelectedCommand.NotifyCanExecuteChanged();
ReverseSelectedCommand.NotifyCanExecuteChanged();
}
private void OnFilteredPlaylistSongsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (IsUserUpdating == false)
return;
// A drag-reorder in the ListView raises a Remove followed by an Add for the same item.
// Reordering is only enabled while unfiltered, so view indices map 1:1 to Playlist.Songs.
switch (e.Action)
{
case NotifyCollectionChangedAction.Remove:
_pendingMovedSong = e.OldItems?.Cast<PlaylistSong>().FirstOrDefault();
_pendingMovedSongIndex = e.OldStartingIndex;
break;
case NotifyCollectionChangedAction.Add:
PlaylistSong? addedSong = e.NewItems?.Cast<PlaylistSong>().FirstOrDefault();
if (addedSong != null && addedSong == _pendingMovedSong)
{
MoveSongInPlaylist(_pendingMovedSongIndex, e.NewStartingIndex);
}
_pendingMovedSong = null;
_pendingMovedSongIndex = -1;
break;
}
}
private void MoveSongInPlaylist(int oldIndex, int newIndex)
{
if (Playlist == null || oldIndex < 0 || oldIndex == newIndex)
return;
Playlist.MoveSong(oldIndex, newIndex);
}
private void OnPlaylistChanged(object? sender, PlaylistChangedEventArgs e)
{
Playlist = e.NewPlaylist;
UpdateFilteredSongs();
}
private void OnPlaylistUpdated(object? sender, PlaylistUpdatedEventArgs e)
{
if (IsUserUpdating)
return;
switch (e.Action)
{
case PlaylistUpdateAction.Add:
case PlaylistUpdateAction.Remove:
case PlaylistUpdateAction.Refresh:
_dispatcherQueue.TryEnqueue(UpdateFilteredSongs);
break;
case PlaylistUpdateAction.Move:
case PlaylistUpdateAction.Reset:
_dispatcherQueue.TryEnqueue(() => ApplyReorderedSongs(e.Songs));
break;
case PlaylistUpdateAction.Lock:
case PlaylistUpdateAction.Unlock:
IsPlaylistLocked = Playlist?.IsLocked ?? false;
break;
}
}
private void ApplyReorderedSongs(PlaylistSong[] playlistSongs)
{
// UpdateFilteredSongs assumes the projection order matches Playlist.Songs order,
// so remove reordered songs first to let them be reinserted at their new positions.
foreach (PlaylistSong playlistSong in playlistSongs)
{
FilteredPlaylistSongs.Remove(playlistSong);
}
UpdateFilteredSongs();
}
private void OnPlayingSongChanged(object? sender, PlayingSongChangedEventArgs e)
{
PlayingSong = e.NewSong;
if (_isUserInitiatingSongChange)
{
_isUserInitiatingSongChange = false;
}
else
{
PlayingSongChangedAutomatically?.Invoke(this, EventArgs.Empty);
}
}
public async Task PlaySongAsync(PlaylistSong playlistSong)
{
_isUserInitiatingSongChange = true;
await _audioPlayer.LoadAsync(playlistSong, PlaybackMode.LoadAndPlay);
}
public async Task<SongPictureInfo?> GetSongPictureInfoAsync(int hashCode, PlaylistSong playlistSong)
{
_imageCancellationTokens.TryGetValue(hashCode, out CancellationTokenSource? cancellationTokenSource);
cancellationTokenSource?.Cancel();
cancellationTokenSource = new();
_imageCancellationTokens.AddOrUpdate(hashCode, cancellationTokenSource, (_, _) => cancellationTokenSource);
return await _audioImageCache.GetAsync(playlistSong.Song, cancellationTokenSource.Token);
}
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)
{
return await _audioBitmapImageCache.GetAsync(playlistSong.Song, cancellationToken);
}
#region Filtering
private void RestartFilterTimer()
{
if (_filterTimer == null)
{
_filterTimer = _dispatcherQueue.CreateTimer();
_filterTimer.Interval = TimeSpan.FromMilliseconds(300);
_filterTimer.IsRepeating = false;
_filterTimer.Tick += OnFilterTimerTick;
}
_filterTimer.Stop();
_filterTimer.Start();
}
private void OnFilterTimerTick(DispatcherQueueTimer sender, object args)
{
UpdateFilteredSongs();
}
private void UpdateFilteredSongs()
{
if (Playlist == null)
return;
string? filter = Filter;
HashSet<PlaylistSong> updatedFilterSet = [.. Playlist.Songs.Where(playlistSong => IsFiltered(playlistSong.Song, filter))];
for (int i = FilteredPlaylistSongs.Count - 1; i >= 0; i--)
{
PlaylistSong playlistSong = FilteredPlaylistSongs[i];
bool inFilterSet = updatedFilterSet.Contains(playlistSong);
if (!inFilterSet)
FilteredPlaylistSongs.RemoveAt(i);
}
HashSet<PlaylistSong> currentSet = [.. FilteredPlaylistSongs];
int insertionIndex = 0;
foreach (PlaylistSong playlistSong in Playlist.Songs)
{
bool inFilterSet = updatedFilterSet.Contains(playlistSong);
bool inCurrentSet = currentSet.Contains(playlistSong);
if (!inFilterSet)
continue;
if (!inCurrentSet)
{
FilteredPlaylistSongs.Insert(insertionIndex, playlistSong);
}
insertionIndex++;
}
}
private static bool IsFiltered(Song song, string? filter)
{
if (string.IsNullOrWhiteSpace(filter))
return true;
var shortFileName = Path.GetFileName(song.FileName);
if (shortFileName.Contains(filter, StringComparison.OrdinalIgnoreCase))
return true;
if (string.IsNullOrWhiteSpace(song.Title) == false && song.Title.Contains(filter, StringComparison.OrdinalIgnoreCase))
return true;
if (string.IsNullOrWhiteSpace(song.Album) == false && song.Album.Contains(filter, StringComparison.OrdinalIgnoreCase))
return true;
if (song.AlbumArtists.Any(x => x.Contains(filter, StringComparison.OrdinalIgnoreCase)))
return true;
if (song.Artists.Any(x => x.Contains(filter, StringComparison.OrdinalIgnoreCase)))
return true;
return false;
}
#endregion
#region Commands
private async Task PlaySongAsync()
{
if (SelectedPlaylistSongs.Count == 0)
return;
await _audioPlayer.LoadAsync(SelectedPlaylistSongs[0], PlaybackMode.LoadAndPlay);
}
private bool AreSongsSelected()
{
return SelectedPlaylistSongs.Count > 0;
}
private bool AreMultipleSongsSelected()
{
return SelectedPlaylistSongs.Count > 1;
}
private async Task NewPlaylistAsync(CancellationToken cancellationToken)
{
string playlistName = await _messageService.InputTextAsync(
$"New Playlist",
"Enter a new name for the playlist:",
"New Playlist");
if (string.IsNullOrWhiteSpace(playlistName))
return;
Playlist playlist = await _playlistManager.AddPlaylistAsync();
playlist.SetName(playlistName);
_playlistManager.CurrentPlaylist = playlist;
}
private async Task AddFilesAsync(CancellationToken cancellationToken)
{
if (Playlist == null)
return;
FilePickerOptions filePickerOptions = new()
{
FileTypeFilter = [GetAudioFileTypes()],
};
string[] fileNames = await _storageProvider.GetFilesAsync(filePickerOptions);
Song[] songs = await _audioFileScanner.GetSongsAsync(fileNames, cancellationToken);
Playlist.AddSongs(songs);
}
private FilePickerFileType GetAudioFileTypes()
{
string[] patterns = [.. _audioEngine.SupportedFormats.Select(format => format.Replace("*", ""))];
return new()
{
Name = "Audio Files",
Patterns = patterns
};
}
private async Task AddFolderAsync(CancellationToken cancellationToken)
{
if (Playlist == null)
return;
string? path = await _storageProvider.GetPathAsync();
if (string.IsNullOrWhiteSpace(path))
return;
Song[] songs = await _audioFileScanner.GetSongsFromPathAsync(path, cancellationToken);
Playlist.AddSongs(songs);
}
public async Task AddFilesAsync(string[] fileNames, CancellationToken cancellationToken)
{
if (Playlist == null)
return;
Song[] songs = await _audioFileScanner.GetSongsAsync(fileNames, cancellationToken);
Playlist.AddSongs(songs);
}
public async Task AddFolderAsync(string path, CancellationToken cancellationToken)
{
if (Playlist == null)
return;
Song[] songs = await _audioFileScanner.GetSongsFromPathAsync(path, cancellationToken);
Playlist.AddSongs(songs);
}
private void RemoveSongs()
{
if (Playlist == null)
return;
if (SelectedPlaylistSongs.Count == 0)
return;
PlaylistSong[] playlistSongs = [.. SelectedPlaylistSongs];
Playlist.RemoveSongs(playlistSongs);
}
private void CutSongs()
{
if (SelectedPlaylistSongs.Count == 0)
return;
CopySelectedSongsToClipboard();
}
private void CopySongs()
{
if (SelectedPlaylistSongs.Count == 0)
return;
CopySelectedSongsToClipboard();
}
private void CopySelectedSongsToClipboard()
{
Song[] songs = [.. SelectedPlaylistSongs.Select(playlistSong => playlistSong.Song)];
DataPackage dataPackage = new()
{
RequestedOperation = DataPackageOperation.Copy
};
dataPackage.Properties.Add("Type", "SongList");
dataPackage.SetData(StandardDataFormats.Text, JsonSerializer.Serialize(songs));
Clipboard.SetContent(dataPackage);
}
private bool CanPasteSongs()
{
if (Playlist == null)
return false;
DataPackageView dataPackageView = Clipboard.GetContent();
if (dataPackageView == null)
return false;
if (dataPackageView.Properties.ContainsKey("Type") == false)
return false;
return dataPackageView.Properties["Type"].ToString() == "SongList";
}
private async Task PasteSongsAsync()
{
if (Playlist == null)
return;
Song[] songs = await GetSongsFromClipboardAsync();
int insertIndex = Playlist.Songs.Count;
if (SelectedPlaylistSongs.Count > 0)
{
int selectedPlaylistSongIndex = Playlist.IndexOf(SelectedPlaylistSongs[0]);
if (selectedPlaylistSongIndex >= 0)
insertIndex = selectedPlaylistSongIndex + 1;
}
Playlist.AddSongs(songs, insertIndex);
}
private static async Task<Song[]> GetSongsFromClipboardAsync()
{
DataPackageView dataPackageView = Clipboard.GetContent();
string data = await dataPackageView.GetTextAsync(StandardDataFormats.Text);
return JsonSerializer.Deserialize<Song[]>(data) ?? [];
}
private void OpenFileLocation()
{
if (SelectedPlaylistSongs.Count == 0)
return;
string argument = "/select, \"" + SelectedPlaylistSongs[0].Song.FileName + "\"";
Process.Start("explorer.exe", argument);
}
private async Task RefreshTagsAsync(CancellationToken cancellationToken)
{
if (Playlist is null)
return;
// TODO: Have different method for selected songs versus all songs in the playlist
PlaylistSong[] targets = SelectedPlaylistSongs.Count > 0
? [.. SelectedPlaylistSongs]
: [.. Playlist.Songs];
string[] fileNames = [.. targets
.Select(ps => ps.Song.FileName)
.Distinct(StringComparer.OrdinalIgnoreCase)];
Song[] songs = await _audioFileScanner.GetSongsAsync(fileNames, cancellationToken);
Playlist.ImportTags(songs);
}
private void RemoveMissingSongs()
{
Playlist?.RemoveMissingSongs();
}
private void RemoveDuplicateSongs()
{
Playlist?.RemoveDuplicateSongs();
}
private async Task DeletePlaylistAsync()
{
if (Playlist == null)
return;
bool confirmed = await _messageService.ConfirmAsync($"Delete Playlist - {Playlist.Name}", "Are you sure you want to delete this playlist?");
if (!confirmed)
return;
_playlistManager.RemovePlaylist(Playlist);
}
private void SortAllSongs(SortItem? sortItem)
{
if (Playlist == null || sortItem == null)
return;
Playlist.SortSongs([.. Playlist.Songs], [.. sortItem.SortOptions]);
}
private void SortSelectedSongs(SortItem? sortItem)
{
if (Playlist == null || sortItem == null)
return;
if (SelectedPlaylistSongs.Count < 2)
return;
string[] selectedPlaylistSongIds = [.. SelectedPlaylistSongs.Select(x => x.UID)];
Playlist.SortSongs([.. SelectedPlaylistSongs], [.. sortItem.SortOptions]);
RestoreSelectedSongs(selectedPlaylistSongIds);
}
private void RestoreSelectedSongs(string[] playlistSongIds)
{
_dispatcherQueue.TryEnqueue(() =>
{
PlaylistSong[] selectedPlaylistSongs = [.. Playlist?.Songs.Where(x => playlistSongIds.Contains(x.UID)) ?? []];
SelectedPlaylistSongs = new ObservableCollection<PlaylistSong>(selectedPlaylistSongs);
});
}
private void RandomizeAllSongs()
{
if (Playlist == null || Playlist.Songs.Count < 2)
return;
Playlist.Randomize([.. Playlist.Songs]);
}
private void RandomizeSelectedSongs()
{
if (Playlist == null || SelectedPlaylistSongs.Count < 2)
return;
string[] selectedPlaylistSongIds = [.. SelectedPlaylistSongs.Select(x => x.UID)];
Playlist.Randomize([.. SelectedPlaylistSongs]);
RestoreSelectedSongs(selectedPlaylistSongIds);
}
private void ReverseAllSongs()
{
if (Playlist == null || Playlist.Songs.Count < 2)
return;
Playlist.Reverse([.. Playlist.Songs]);
}
private void ReverseSelectedSongs()
{
if (Playlist == null || SelectedPlaylistSongs.Count < 2)
return;
string[] selectedPlaylistSongIds = [.. SelectedPlaylistSongs.Select(x => x.UID)];
Playlist.Reverse([.. SelectedPlaylistSongs]);
RestoreSelectedSongs(selectedPlaylistSongIds);
}
private void LockPlaylist()
{
Playlist?.Lock();
}
private void UnlockPlaylist()
{
Playlist?.Unlock();
}
private async Task RenamePlaylist()
{
if (Playlist == null)
return;
string newName = await _messageService.InputTextAsync(
$"Rename Playlist - {Playlist.Name}",
"Enter a new name for the playlist:",
Playlist.Name ?? string.Empty);
if (string.IsNullOrWhiteSpace(newName))
return;
Playlist.SetName(newName);
}
#endregion
}
public class SortItem
{
public string Name { get; set; } = string.Empty;
public IReadOnlyCollection<SortOption> SortOptions { get; set; } = [];
}

View File

@@ -0,0 +1,76 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Harmonia.Core.Playlists;
using System;
using System.Linq;
namespace Harmonia.WinUI.ViewModels;
public partial class PlaylistItemViewModel : ObservableObject
{
public Playlist Playlist { get; private set; }
private string _name = string.Empty;
public string Name
{
get
{
return _name;
}
set
{
SetProperty(ref _name, value);
}
}
private string _summary = string.Empty;
public string Summary
{
get
{
return _summary;
}
set
{
SetProperty(ref _summary, value);
}
}
private bool _isLocked;
public bool IsLocked
{
get
{
return _isLocked;
}
set
{
SetProperty(ref _isLocked, value);
}
}
public PlaylistItemViewModel(Playlist playlist)
{
Playlist = playlist;
Playlist.PlaylistUpdated += OnPlaylistUpdated;
UpdateMetaData();
}
public void Unsubscribe()
{
Playlist.PlaylistUpdated -= OnPlaylistUpdated;
}
private void OnPlaylistUpdated(object? sender, EventArgs e)
{
UpdateMetaData();
}
private void UpdateMetaData()
{
Name = Playlist.Name ?? "Untitled Playlist";
IsLocked = Playlist.IsLocked;
TimeSpan total = TimeSpan.FromSeconds(Playlist.Songs.Sum(s => s.Song.Length.TotalSeconds));
Summary = $"{Playlist.Songs.Count} songs / {total:h\\:mm\\:ss}";
}
}

View File

@@ -1,574 +0,0 @@
using CommunityToolkit.Mvvm.Input;
using Harmonia.Core.Caching;
using Harmonia.Core.Engine;
using Harmonia.Core.Imaging;
using Harmonia.Core.Models;
using Harmonia.Core.Player;
using Harmonia.Core.Playlists;
using Harmonia.Core.Scanner;
using Harmonia.WinUI.Caching;
using Harmonia.WinUI.Storage;
using Microsoft.UI.Xaml.Media.Imaging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using System.Windows.Input;
using Windows.ApplicationModel.DataTransfer;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using Timer = System.Timers.Timer;
namespace Harmonia.WinUI.ViewModels;
public partial class PlaylistViewModel : ViewModelBase
{
private readonly IPlaylistManager _playlistManager;
private readonly IAudioPlayer _audioPlayer;
private readonly IAudioImageCache _audioImageCache;
private readonly IAudioBitmapImageCache _audioBitmapImageCache;
private readonly IAudioFileScanner _audioFileScanner;
private readonly IAudioEngine _audioEngine;
private readonly IStorageProvider _storageProvider;
private readonly DispatcherQueue _dispatcherQueue;
private readonly ConcurrentDictionary<int, CancellationTokenSource> _imageCancellationTokens = [];
private Timer? _filterTimer;
public Playlist? Playlist { get; private set; }
private PlaylistSong? _playingSong;
public PlaylistSong? PlayingSong
{
get
{
return _playingSong;
}
set
{
SetProperty(ref _playingSong, value);
}
}
private ObservableCollection<PlaylistSong> _playlistSongs = [];
public ObservableCollection<PlaylistSong> PlaylistSongs
{
get
{
return _playlistSongs;
}
set
{
SetProperty(ref _playlistSongs, value);
}
}
private string? _filter;
public string? Filter
{
get
{
return _filter;
}
set
{
SetProperty(ref _filter, value);
RestartFilterTimer();
}
}
private ObservableCollection<PlaylistSong> _filteredPlaylistSongs = [];
public ObservableCollection<PlaylistSong> FilteredPlaylistSongs
{
get
{
return _filteredPlaylistSongs;
}
set
{
SetProperty(ref _filteredPlaylistSongs, value);
}
}
private ObservableCollection<PlaylistSong> _selectedPlaylistSongs = [];
public ObservableCollection<PlaylistSong> SelectedPlaylistSongs
{
get
{
return _selectedPlaylistSongs;
}
set
{
SetProperty(ref _selectedPlaylistSongs, value);
}
}
public ICommand PlaySongCommand => new AsyncRelayCommand(PlaySongAsync, AreSongsSelected);
public ICommand AddFilesCommand => new AsyncRelayCommand(AddFilesAsync);
public ICommand AddFolderCommand => new AsyncRelayCommand(AddFolderAsync);
public ICommand RemoveSongsCommand => new RelayCommand(RemoveSongs, AreSongsSelected);
public ICommand CutSongsCommand => new RelayCommand(CutSongs, AreSongsSelected);
public ICommand CopySongsCommand => new RelayCommand(CopySongs, AreSongsSelected);
public ICommand PasteSongsCommand => new AsyncRelayCommand(PasteSongsAsync, CanPasteSongs);
public ICommand OpenFileLocationCommand => new RelayCommand(OpenFileLocation, AreSongsSelected);
public ICommand RefreshTagsCommand => new RelayCommand(RefreshTags);
public ICommand RemoveMissingSongsCommand => new RelayCommand(RemoveMissingSongs);
public ICommand RemoveDuplicateSongsCommand => new RelayCommand(RemoveDuplicateSongs);
public bool IsUserUpdating { get; set; }
private bool _isUserInitiatingSongChange;
public event EventHandler? PlayingSongChangedAutomatically;
public PlaylistViewModel(
IAudioPlayer audioPlayer,
IAudioImageCache audioImageCache,
IAudioBitmapImageCache audioBitmapImageCache,
IAudioFileScanner audioFileScanner,
IAudioEngine audioEngine,
IStorageProvider storageProvider,
IPlaylistManager playlistManager)
{
_playlistManager = playlistManager;
//_playlistManager.CurrentPlaylistChanged += OnPlaylistChanged;
_audioPlayer = audioPlayer;
_audioPlayer.PlaylistChanged += OnPlaylistChanged;
_audioPlayer.PlayingSongChanged += OnPlayingSongChanged;
_audioImageCache = audioImageCache;
_audioBitmapImageCache = audioBitmapImageCache;
_audioFileScanner = audioFileScanner;
_audioEngine = audioEngine;
_storageProvider = storageProvider;
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
FilteredPlaylistSongs.CollectionChanged += OnFilteredPlaylistSongsCollectionChanged;
Playlist = _playlistManager.CurrentPlaylist; // Testing
UpdatePlaylistSongs(Playlist);
// Testing
//Task.Run(() => PlayDemoSong(playlistRepository));
}
private void OnFilteredPlaylistSongsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (IsUserUpdating == false)
return;
int x = 1;
}
//private async Task PlayDemoSong(IPlaylistRepository playlistRepository)
//{
// if (playlistRepository.Get().Count == 0)
// {
// playlistRepository.AddPlaylist();
// }
// Playlist playlist = playlistRepository.Get().First();
// if (playlist.Songs.Count > 0)
// await _audioPlayer.LoadAsync(playlist.Songs[0], PlaybackMode.LoadOnly);
//}
private void UpdatePlaylistSongs(Playlist? playlist)
{
PlaylistSong[] playlistSongs = playlist?.Songs.ToArray() ?? [];
PlaylistSongs = [.. playlistSongs];
UpdateFilteredSongs();
}
private void OnPlaylistChanged(object? sender, EventArgs e)
{
if (Playlist != null)
{
Playlist.PlaylistUpdated -= OnPlaylistUpdated;
}
Playlist = _audioPlayer.Playlist;
if (Playlist != null)
{
Playlist.PlaylistUpdated += OnPlaylistUpdated;
}
PlaylistSong[] playlistSongs = _audioPlayer.Playlist?.Songs.ToArray() ?? [];
PlaylistSongs = [.. playlistSongs];
UpdateFilteredSongs();
}
private void OnPlaylistUpdated(object? sender, PlaylistUpdatedEventArgs e)
{
if (IsUserUpdating)
return;
switch (e.Action)
{
case PlaylistUpdateAction.Add:
_dispatcherQueue.TryEnqueue(() => AddSongs(e.Songs, e.Index));
break;
case PlaylistUpdateAction.Remove:
_dispatcherQueue.TryEnqueue(() => RemoveSongsFromCollection(e.Songs));
break;
}
}
private void AddSongs(PlaylistSong[] playlistSongs, int index = 0)
{
// TODO: Performance improvements
int currentIndex = index;
foreach (PlaylistSong playlistSong in playlistSongs)
{
PlaylistSongs.Insert(currentIndex++, playlistSong);
}
UpdateFilteredSongs();
}
private void RemoveSongsFromCollection(PlaylistSong[] playlistSongs)
{
foreach (PlaylistSong playlistSong in playlistSongs)
{
PlaylistSongs.Remove(playlistSong);
}
UpdateFilteredSongs();
}
private void OnPlayingSongChanged(object? sender, EventArgs e)
{
PlayingSong = _audioPlayer.PlayingSong;
if (_isUserInitiatingSongChange)
{
_isUserInitiatingSongChange = false;
}
else
{
PlayingSongChangedAutomatically?.Invoke(this, EventArgs.Empty);
}
}
public async Task PlaySongAsync(PlaylistSong playlistSong)
{
_isUserInitiatingSongChange = true;
await _audioPlayer.LoadAsync(playlistSong, PlaybackMode.LoadAndPlay);
}
public async Task<SongPictureInfo?> GetSongPictureInfoAsync(int hashCode, PlaylistSong playlistSong)
{
_imageCancellationTokens.TryGetValue(hashCode, out CancellationTokenSource? cancellationTokenSource);
cancellationTokenSource?.Cancel();
cancellationTokenSource = new();
_imageCancellationTokens.AddOrUpdate(hashCode, cancellationTokenSource, (_, _) => cancellationTokenSource);
return await _audioImageCache.GetAsync(playlistSong.Song, cancellationTokenSource.Token);
}
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)
{
return await _audioBitmapImageCache.GetAsync(playlistSong.Song, cancellationToken);
}
#region Filtering
private void RestartFilterTimer()
{
if (_filterTimer == null)
{
_filterTimer = new Timer(300);
_filterTimer.Elapsed += OnFilterTimerElapsed;
_filterTimer.Start();
}
else
{
_filterTimer.Interval = 300;
}
}
private void OnFilterTimerElapsed(object? sender, ElapsedEventArgs e)
{
if (_filterTimer == null)
return;
_filterTimer.Stop();
_filterTimer.Dispose();
_filterTimer = null;
_dispatcherQueue.TryEnqueue(UpdateFilteredSongs);
}
private void UpdateFilteredSongs()
{
if (Playlist == null)
return;
List<PlaylistSong> filteredPlaylistSongs = [.. Playlist.Songs.Where(playlistSong => IsFiltered(playlistSong.Song))];
//FilteredPlaylistSongs = [.. filteredPlaylistSongs];
for (int i = FilteredPlaylistSongs.Count - 1; i >= 0; i--)
{
PlaylistSong playlistSong = FilteredPlaylistSongs[i];
bool inPlaylist = Playlist.Songs.Contains(playlistSong);
bool inFilter = filteredPlaylistSongs.Contains(playlistSong);
if (!inPlaylist || !inFilter)
{
FilteredPlaylistSongs.Remove(playlistSong);
}
}
int insertionIndex = 0;
foreach (PlaylistSong playlistSong in Playlist.Songs)
{
bool inFilter = filteredPlaylistSongs.Contains(playlistSong);
bool inCurrentFilteredList = FilteredPlaylistSongs.Contains(playlistSong);
if (inFilter)
{
if (!inCurrentFilteredList)
{
FilteredPlaylistSongs.Insert(insertionIndex, playlistSong);
}
insertionIndex++;
}
}
}
private bool IsFiltered(Song song)
{
if (string.IsNullOrWhiteSpace(Filter))
return true;
var shortFileName = Path.GetFileName(song.FileName);
if (shortFileName.Contains(Filter, StringComparison.OrdinalIgnoreCase))
return true;
if (string.IsNullOrWhiteSpace(song.Title) == false && song.Title.Contains(Filter, StringComparison.OrdinalIgnoreCase))
return true;
if (string.IsNullOrWhiteSpace(song.Album) == false && song.Album.Contains(Filter, StringComparison.OrdinalIgnoreCase))
return true;
if (song.AlbumArtists.Any(x => x.Contains(Filter, StringComparison.OrdinalIgnoreCase)))
return true;
if (song.Artists.Any(x => x.Contains(Filter, StringComparison.OrdinalIgnoreCase)))
return true;
return false;
}
#endregion
#region Commands
private async Task PlaySongAsync()
{
if (SelectedPlaylistSongs.Count == 0)
return;
await _audioPlayer.LoadAsync(SelectedPlaylistSongs[0], PlaybackMode.LoadAndPlay);
}
private bool AreSongsSelected()
{
return SelectedPlaylistSongs.Count > 0;
}
private async Task AddFilesAsync(CancellationToken cancellationToken)
{
if (Playlist == null)
return;
FilePickerOptions filePickerOptions = new()
{
FileTypeFilter = [GetAudioFileTypes()],
};
string[] fileNames = await _storageProvider.GetFilesAsync(filePickerOptions);
Song[] songs = await _audioFileScanner.GetSongsAsync(fileNames, cancellationToken);
Playlist.AddSongs(songs);
}
private FilePickerFileType GetAudioFileTypes()
{
string[] patterns = [.. _audioEngine.SupportedFormats.Select(format => format.Replace("*", ""))];
return new()
{
Name = "Audio Files",
Patterns = patterns
};
}
private async Task AddFolderAsync(CancellationToken cancellationToken)
{
if (Playlist == null)
return;
string? path = await _storageProvider.GetPathAsync();
if (string.IsNullOrWhiteSpace(path))
return;
Song[] songs = await _audioFileScanner.GetSongsFromPathAsync(path, cancellationToken);
Playlist.AddSongs(songs);
}
public async Task AddFilesAsync(string[] fileNames, CancellationToken cancellationToken)
{
if (Playlist == null)
return;
Song[] songs = await _audioFileScanner.GetSongsAsync(fileNames, cancellationToken);
Playlist.AddSongs(songs);
}
public async Task AddFolderAsync(string path, CancellationToken cancellationToken)
{
if (Playlist == null)
return;
Song[] songs = await _audioFileScanner.GetSongsFromPathAsync(path, cancellationToken);
Playlist.AddSongs(songs);
}
private void RemoveSongs()
{
if (Playlist == null)
return;
if (SelectedPlaylistSongs.Count == 0)
return;
PlaylistSong[] playlistSongs = [.. SelectedPlaylistSongs];
Playlist.RemoveSongs(playlistSongs);
}
private void CutSongs()
{
if (SelectedPlaylistSongs.Count == 0)
return;
CopySelectedSongsToClipboard();
}
private void CopySongs()
{
if (SelectedPlaylistSongs.Count == 0)
return;
CopySelectedSongsToClipboard();
}
private void CopySelectedSongsToClipboard()
{
Song[] songs = [.. SelectedPlaylistSongs.Select(playlistSong => playlistSong.Song)];
DataPackage dataPackage = new()
{
RequestedOperation = DataPackageOperation.Copy
};
dataPackage.Properties.Add("Type", "SongList");
dataPackage.SetData(StandardDataFormats.Text, JsonSerializer.Serialize(songs));
Clipboard.SetContent(dataPackage);
}
private bool CanPasteSongs()
{
if (Playlist == null || SelectedPlaylistSongs.Count == 0)
return false;
DataPackageView dataPackageView = Clipboard.GetContent();
if (dataPackageView == null)
return false;
if (dataPackageView.Properties.ContainsKey("Type") == false)
return false;
return dataPackageView.Properties["Type"].ToString() == "SongList";
}
private async Task PasteSongsAsync()
{
if (Playlist == null || SelectedPlaylistSongs.Count == 0)
return;
int selectedPlaylistSongIndex = Playlist.Songs.IndexOf(SelectedPlaylistSongs[0]);
if (selectedPlaylistSongIndex == -1)
return;
Song[] songs = await GetSongsFromClipboardAsync();
Playlist.AddSongs(songs, selectedPlaylistSongIndex + 1);
}
private static async Task<Song[]> GetSongsFromClipboardAsync()
{
DataPackageView dataPackageView = Clipboard.GetContent();
string data = await dataPackageView.GetTextAsync(StandardDataFormats.Text);
return JsonSerializer.Deserialize<Song[]>(data) ?? [];
}
private void OpenFileLocation()
{
if (SelectedPlaylistSongs.Count == 0)
return;
string argument = "/select, \"" + SelectedPlaylistSongs[0].Song.FileName + "\"";
Process.Start("explorer.exe", argument);
}
private void RefreshTags()
{
//Playlist?.RefreshTags();
}
private void RemoveMissingSongs()
{
Playlist?.RemoveMissingSongs();
}
private void RemoveDuplicateSongs()
{
Playlist?.RemoveDuplicateSongs();
}
#endregion
}

View File

@@ -0,0 +1,186 @@
using CommunityToolkit.Mvvm.Input;
using Harmonia.Core.Player;
using Harmonia.Core.Playlists;
using Harmonia.WinUI.Messaging;
using Microsoft.UI.Dispatching;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
namespace Harmonia.WinUI.ViewModels;
public partial class PlaylistsViewModel : ViewModelBase
{
private readonly IPlaylistManager _playlistManager;
private readonly IAudioPlayer _audioPlayer;
private readonly IMessageService _messageService;
private readonly DispatcherQueue _dispatcherQueue;
private ObservableCollection<PlaylistItemViewModel> _playlists = [];
public ObservableCollection<PlaylistItemViewModel> Playlists
{
get
{
return _playlists;
}
set
{
SetProperty(ref _playlists, value);
}
}
private PlaylistItemViewModel? _selectedPlaylist = null;
public PlaylistItemViewModel? SelectedPlaylist
{
get
{
return _selectedPlaylist;
}
set
{
if (SetProperty(ref _selectedPlaylist, value))
{
_playlistManager.CurrentPlaylist = value?.Playlist;
}
}
}
private PlaylistItemViewModel? _activePlaylist = null;
public PlaylistItemViewModel? ActivePlaylist
{
get
{
return _activePlaylist;
}
set
{
SetProperty(ref _activePlaylist, value);
}
}
private PlaylistItemViewModel? _flyoutPlaylist = null;
public PlaylistItemViewModel? FlyoutPlaylist
{
get
{
return _flyoutPlaylist;
}
set
{
SetProperty(ref _flyoutPlaylist, value);
}
}
public IAsyncRelayCommand<PlaylistItemViewModel?> RenamePlaylistCommand { get; }
public IRelayCommand<PlaylistItemViewModel?> LockPlaylistCommand { get; }
public IRelayCommand<PlaylistItemViewModel?> UnlockPlaylistCommand { get; }
public IAsyncRelayCommand<PlaylistItemViewModel?> DeletePlaylistCommand { get; }
public PlaylistsViewModel(IPlaylistManager playlistManager, IAudioPlayer audioPlayer, IMessageService messageService)
{
_playlistManager = playlistManager;
_playlistManager.PlaylistAdded += OnPlaylistAdded;
_playlistManager.PlaylistRemoved += OnPlaylistRemoved;
_playlistManager.CurrentPlaylistChanged += OnCurrentPlaylistChanged;
_messageService = messageService;
_audioPlayer = audioPlayer;
_audioPlayer.PlayingSongChanged += OnPlayingSongChanged;
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
_playlists = new ObservableCollection<PlaylistItemViewModel>(_playlistManager.Playlists.Select(p => new PlaylistItemViewModel(p)));
_selectedPlaylist = _playlists.FirstOrDefault(pv => pv.Playlist == _playlistManager.CurrentPlaylist);
_activePlaylist = _playlists.FirstOrDefault(pv => pv.Playlist == _audioPlayer.Playlist);
RenamePlaylistCommand = new AsyncRelayCommand<PlaylistItemViewModel?>(RenamePlaylist);
LockPlaylistCommand = new RelayCommand<PlaylistItemViewModel?>(LockPlaylist);
UnlockPlaylistCommand = new RelayCommand<PlaylistItemViewModel?>(UnlockPlaylist);
DeletePlaylistCommand = new AsyncRelayCommand<PlaylistItemViewModel?>(DeletePlaylistAsync);
}
private void OnCurrentPlaylistChanged(object? sender, EventArgs e)
{
SelectedPlaylist = _playlists.FirstOrDefault(pv => pv.Playlist == _playlistManager.CurrentPlaylist);
}
private void OnPlaylistAdded(object? sender, PlaylistAddedEventArgs e)
{
Playlists.Add(new PlaylistItemViewModel(e.Playlist));
}
private void OnPlaylistRemoved(object? sender, PlaylistRemovedEventArgs e)
{
var playlistView = Playlists.FirstOrDefault(pv => pv.Playlist == e.Playlist);
if (playlistView != null)
{
playlistView.Unsubscribe();
Playlists.Remove(playlistView);
}
}
private void OnPlayingSongChanged(object? sender, PlayingSongChangedEventArgs e)
{
_dispatcherQueue.TryEnqueue(UpdateActivePlaylist);
}
private void UpdateActivePlaylist()
{
ActivePlaylist = Playlists.FirstOrDefault(pv => pv.Playlist == _audioPlayer.Playlist);
}
private async Task RenamePlaylist(PlaylistItemViewModel? playlistItem)
{
if (playlistItem is null || playlistItem.Playlist is null)
return;
Playlist playlist = playlistItem.Playlist;
string newName = await _messageService.InputTextAsync(
$"Rename Playlist - {playlist.Name}",
"Enter a new name for the playlist:",
playlist.Name ?? string.Empty);
if (string.IsNullOrWhiteSpace(newName))
return;
playlist.SetName(newName);
}
private void LockPlaylist(PlaylistItemViewModel? playlistItem)
{
if (playlistItem is null || playlistItem.Playlist is null)
return;
Playlist playlist = playlistItem.Playlist;
playlist.Lock();
}
private void UnlockPlaylist(PlaylistItemViewModel? playlistItem)
{
if (playlistItem is null || playlistItem.Playlist is null)
return;
Playlist playlist = playlistItem.Playlist;
playlist.Unlock();
}
private async Task DeletePlaylistAsync(PlaylistItemViewModel? playlistItem)
{
if (playlistItem is null || playlistItem.Playlist is null)
return;
Playlist playlist = playlistItem.Playlist;
bool confirmed = await _messageService.ConfirmAsync($"Delete Playlist - {playlist.Name}", "Are you sure you want to delete this playlist?");
if (!confirmed)
return;
_playlistManager.RemovePlaylist(playlist);
}
}

View File

@@ -1,4 +1,4 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Harmonia.WinUI.ViewModels; namespace Harmonia.WinUI.ViewModels;
@@ -13,6 +13,9 @@ public class ViewModelLocator
public static PlayingSongViewModel PlayingSongViewModel public static PlayingSongViewModel PlayingSongViewModel
=> App.ServiceProvider.GetRequiredService<PlayingSongViewModel>(); => App.ServiceProvider.GetRequiredService<PlayingSongViewModel>();
public static PlaylistViewModel PlaylistViewModel public static PlaylistsViewModel PlaylistsViewModel
=> App.ServiceProvider.GetRequiredService<PlaylistViewModel>(); => App.ServiceProvider.GetRequiredService<PlaylistsViewModel>();
public static PlaylistDetailViewModel PlaylistDetailViewModel
=> App.ServiceProvider.GetRequiredService<PlaylistDetailViewModel>();
} }

View File

@@ -14,13 +14,45 @@
<SolidColorBrush x:Key="SongItemTitleBrush" Color="#dddddd"/> <SolidColorBrush x:Key="SongItemTitleBrush" Color="#dddddd"/>
<SolidColorBrush x:Key="SongItemSubtitleBrush" Color="#aaaaaa"/> <SolidColorBrush x:Key="SongItemSubtitleBrush" Color="#aaaaaa"/>
<SolidColorBrush x:Key="SongItemTitleBrush2" Color="#FCF8ED" />
<SolidColorBrush x:Key="SongItemSubtitleBrush2" Color="#B5B8B8" />
<LinearGradientBrush x:Key="LightGradientBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#F6F2E3" Offset="0"/>
<GradientStop Color="#C9B59F" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="DarkGradientBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#F9EBC6" Offset="0"/>
<GradientStop Color="#A66831" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="PurpleGradientBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#EFD6F5" Offset="0"/>
<GradientStop Color="#AC8CC4" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="SliderGradientBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#E1B88A" Offset="0"/>
<GradientStop Color="#E9B578" Offset="1"/>
</LinearGradientBrush>
<Style x:Key="PlayerGrid" TargetType="Grid"> <Style x:Key="PlayerGrid" TargetType="Grid">
<Setter Property="Background" Value="#1a1a1a"/>
<Setter Property="Padding" Value="10"/> <Setter Property="Padding" Value="10"/>
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#101215" Offset="0"/>
<GradientStop Color="#101214" Offset="1"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Setter Property="BorderBrush" Value="#3B332B" />
<Setter Property="BorderThickness" Value="1" />
</Style> </Style>
<Style x:Key="SongTitleTextBlock" TargetType="TextBlock"> <Style x:Key="SongTitleTextBlock" TargetType="TextBlock">
<Setter Property="FontSize" Value="16"/> <Setter Property="FontSize" Value="16"/>
<Setter Property="FontFamily" Value="{StaticResource AppFontFamilySemiBold}"/>
<Setter Property="FontWeight" Value="SemiBold"/> <Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/> <Setter Property="TextTrimming" Value="CharacterEllipsis"/>
<Setter Property="Foreground" Value="{StaticResource SongItemTitleBrush}"/> <Setter Property="Foreground" Value="{StaticResource SongItemTitleBrush}"/>
@@ -68,11 +100,24 @@
Grid.Column="1" Grid.Column="1"
Value="{Binding CurrentPosition, Mode=TwoWay, UpdateSourceTrigger=Explicit}" Value="{Binding CurrentPosition, Mode=TwoWay, UpdateSourceTrigger=Explicit}"
Minimum="0" Minimum="0"
Foreground="{StaticResource SliderGradientBrush}"
Maximum="{Binding MaxPosition, Mode=OneWay}" Maximum="{Binding MaxPosition, Mode=OneWay}"
IsEnabled="{Binding CanUpdatePosition, Mode=OneWay}" IsEnabled="{Binding CanUpdatePosition, Mode=OneWay}"
ThumbToolTipValueConverter="{StaticResource SecondsToString}" ThumbToolTipValueConverter="{StaticResource SecondsToString}"
VerticalAlignment="Center" VerticalAlignment="Center"
VerticalContentAlignment="Center" /> VerticalContentAlignment="Center">
<Slider.Resources>
<!-- Thumb (the "ball") -->
<StaticResource x:Key="SliderThumbBackground" ResourceKey="DarkGradientBrush"/>
<StaticResource x:Key="SliderThumbBackgroundPointerOver" ResourceKey="DarkGradientBrush"/>
<StaticResource x:Key="SliderThumbBackgroundPressed" ResourceKey="DarkGradientBrush"/>
<!-- Filled portion of the track (defaults to system accent on hover/press) -->
<StaticResource x:Key="SliderTrackValueFill" ResourceKey="SliderGradientBrush"/>
<StaticResource x:Key="SliderTrackValueFillPointerOver" ResourceKey="SliderGradientBrush"/>
<StaticResource x:Key="SliderTrackValueFillPressed" ResourceKey="SliderGradientBrush"/>
</Slider.Resources>
</Slider>
<Border Grid.Column="2" Background="Transparent" Padding="0 8" Width="60" CornerRadius="4" VerticalAlignment="Center" Margin="6 0 0 0"> <Border Grid.Column="2" Background="Transparent" Padding="0 8" Width="60" CornerRadius="4" VerticalAlignment="Center" Margin="6 0 0 0">
<TextBlock <TextBlock
@@ -88,14 +133,14 @@
<!-- 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">
<Grid Margin="0 0 10 0"> <Grid Margin="0 0 10 0" BorderBrush="#2D2930" BorderThickness="1" CornerRadius="4">
<Image Source="{Binding SongImageSource, Mode=OneWay}" Style="{StaticResource SongImage}"></Image> <Image Source="{Binding SongImageSource, Mode=OneWay}" Style="{StaticResource SongImage}"></Image>
<Canvas Background="#19000000"></Canvas> <Canvas Background="#19000000"></Canvas>
</Grid> </Grid>
<StackPanel VerticalAlignment="Center"> <StackPanel VerticalAlignment="Center">
<TextBlock Foreground="#dddddd" FontWeight="SemiBold" FontSize="16" Text="{Binding Song, Converter={StaticResource SongTitle}}"></TextBlock> <TextBlock Foreground="{StaticResource SongItemTitleBrush2}" FontFamily="{StaticResource AppFontFamilySemiBold}" FontWeight="SemiBold" FontSize="16" Text="{Binding Song, Converter={StaticResource SongTitle}}"></TextBlock>
<TextBlock Foreground="#aaaaaa" FontSize="14" Text="{Binding Song.Artists, Converter={StaticResource ArtistsToString}}"></TextBlock> <TextBlock Foreground="{StaticResource SongItemSubtitleBrush2}" FontSize="14" Text="{Binding Song.Artists, Converter={StaticResource ArtistsToString}}"></TextBlock>
<TextBlock Foreground="#aaaaaa" FontSize="14" Text="{Binding Song.Album}"></TextBlock> <TextBlock Foreground="{StaticResource SongItemSubtitleBrush2}" FontSize="14" Text="{Binding Song.Album}"></TextBlock>
<TextBlock Foreground="#777" FontSize="12" Visibility="{Binding Song, Converter={StaticResource NullVisibility}}"> <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>
@@ -106,23 +151,103 @@
<!-- Playback Actions --> <!-- Playback Actions -->
<Grid Grid.Row="1" Grid.Column="1" VerticalAlignment="Center" HorizontalAlignment="Center"> <Grid Grid.Row="1" Grid.Column="1" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="16"> <StackPanel Orientation="Horizontal" Spacing="16">
<!--<Button Style="{StaticResource FlatButton}" Command="{Binding PreviousSongCommand}">
<Path Style="{StaticResource FlatButtonPath}" Fill="{StaticResource LightGradientBrush}" Data="{StaticResource ShuffleIcon}"></Path>
</Button>-->
<Button Style="{StaticResource FlatButton}" Command="{Binding PreviousSongCommand}"> <Button Style="{StaticResource FlatButton}" Command="{Binding PreviousSongCommand}">
<Path Style="{StaticResource FlatButtonPath}" Data="{StaticResource SkipStartFillIcon}"></Path> <Path Style="{StaticResource FlatButtonPath}" Fill="{StaticResource LightGradientBrush}" Data="{StaticResource SkipStartFillIcon}"></Path>
</Button> </Button>
<Button Style="{StaticResource FlatButton}" Command="{Binding StopSongCommand}"> <Button Style="{StaticResource FlatButton}" Command="{Binding StopSongCommand}">
<Path Style="{StaticResource FlatButtonPath}" Data="{StaticResource StopFillIcon}"></Path> <Path Style="{StaticResource FlatButtonPath}" Fill="{StaticResource LightGradientBrush}" Data="{StaticResource StopFillIcon}"></Path>
</Button> </Button>
<Button Style="{StaticResource FlatButton}" Command="{Binding PlaySongCommand}"> <Button Style="{StaticResource FlatButton}" Command="{Binding PlaySongCommand}" Padding="0"
<Path Style="{StaticResource FlatButtonPath-Large}" Data="{StaticResource PlayFillIcon}"></Path> PointerEntered="PlayButton_PointerEntered" PointerExited="PlayButton_PointerExited">
<Button.Resources>
<!-- Keep background transparent on hover/press -->
<SolidColorBrush x:Key="ButtonBackgroundPointerOver" Color="Transparent"/>
<SolidColorBrush x:Key="ButtonBackgroundPressed" Color="Transparent"/>
<Storyboard x:Name="PlayHoverEnterStoryboard">
<ColorAnimation Storyboard.TargetName="PlayGradientStopTop" Storyboard.TargetProperty="Color"
To="#FDF6E2" Duration="0:0:0.15" EnableDependentAnimation="True"/>
<ColorAnimation Storyboard.TargetName="PlayGradientStopBottom" Storyboard.TargetProperty="Color"
To="#C98B4E" Duration="0:0:0.15" EnableDependentAnimation="True"/>
<ColorAnimation Storyboard.TargetName="PlayIconGradientStopTop" Storyboard.TargetProperty="Color"
To="#FDF6E2" Duration="0:0:0.15" EnableDependentAnimation="True"/>
<ColorAnimation Storyboard.TargetName="PlayIconGradientStopBottom" Storyboard.TargetProperty="Color"
To="#C98B4E" Duration="0:0:0.15" EnableDependentAnimation="True"/>
</Storyboard>
<Storyboard x:Name="PlayHoverExitStoryboard">
<ColorAnimation Storyboard.TargetName="PlayGradientStopTop" Storyboard.TargetProperty="Color"
To="#F9EBC6" Duration="0:0:0.15" EnableDependentAnimation="True"/>
<ColorAnimation Storyboard.TargetName="PlayGradientStopBottom" Storyboard.TargetProperty="Color"
To="#A66831" Duration="0:0:0.15" EnableDependentAnimation="True"/>
<ColorAnimation Storyboard.TargetName="PlayIconGradientStopTop" Storyboard.TargetProperty="Color"
To="#F9EBC6" Duration="0:0:0.15" EnableDependentAnimation="True"/>
<ColorAnimation Storyboard.TargetName="PlayIconGradientStopBottom" Storyboard.TargetProperty="Color"
To="#A66831" Duration="0:0:0.15" EnableDependentAnimation="True"/>
</Storyboard>
</Button.Resources>
<Border BorderThickness="2" CornerRadius="48" Padding="16">
<Border.BorderBrush>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop x:Name="PlayGradientStopTop" Color="#F9EBC6" Offset="0"/>
<GradientStop x:Name="PlayGradientStopBottom" Color="#A66831" Offset="1"/>
</LinearGradientBrush>
</Border.BorderBrush>
<Path Style="{StaticResource FlatButtonPath}" Data="{StaticResource PlayFillIcon}" Margin="1,0,-1,0">
<Path.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop x:Name="PlayIconGradientStopTop" Color="#F9EBC6" Offset="0"/>
<GradientStop x:Name="PlayIconGradientStopBottom" Color="#A66831" Offset="1"/>
</LinearGradientBrush>
</Path.Fill>
</Path>
</Border>
</Button> </Button>
<Button Style="{StaticResource FlatButton}" Command="{Binding PauseSongCommand}"> <Button Style="{StaticResource FlatButton}" Command="{Binding PauseSongCommand}">
<Path Style="{StaticResource FlatButtonPath}" Data="{StaticResource PauseFillIcon}"></Path> <Path Style="{StaticResource FlatButtonPath}" Fill="{StaticResource LightGradientBrush}" Data="{StaticResource PauseFillIcon}"></Path>
</Button> </Button>
<Button Style="{StaticResource FlatButton}" Command="{Binding NextSongCommand}"> <Button Style="{StaticResource FlatButton}" Command="{Binding NextSongCommand}">
<Path Style="{StaticResource FlatButtonPath}" Data="{StaticResource SkipEndFillIcon}"></Path> <Path Style="{StaticResource FlatButtonPath}" Fill="{StaticResource LightGradientBrush}" Data="{StaticResource SkipEndFillIcon}"></Path>
</Button> </Button>
<!--<Button Style="{StaticResource FlatButton}" Command="{Binding NextSongCommand}">
<Path Style="{StaticResource FlatButtonPath}" Fill="{StaticResource LightGradientBrush}" Data="{StaticResource RepeatIcon}"></Path>
</Button>-->
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- Volume Slider -->
<Grid Grid.Row="1" Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Style="{StaticResource FlatButton}" BorderBrush="{StaticResource DarkGradientBrush}" Command="{Binding ToggleMuteCommand}">
<Viewbox Width="24" Height="24">
<Canvas Width="512" Height="512">
<Path Stretch="None" Fill="{Binding VolumeState, Converter={StaticResource VolumeStateToFill}, ConverterParameter={StaticResource DarkGradientBrush}}" Stroke="{StaticResource DarkGradientBrush}" StrokeThickness="32" StrokeLineJoin="Round" StrokeStartLineCap="Round" StrokeEndLineCap="Round" Data="{Binding VolumeState, Converter={StaticResource VolumeStateToIcon}}"></Path>
</Canvas>
</Viewbox>
</Button>
<Slider
Width="200"
Minimum="0.0"
Maximum="1.0"
StepFrequency="0.01"
VerticalAlignment="Center"
PointerWheelChanged="VolumeSlider_PointerWheelChanged"
Value="{Binding Volume, Mode=TwoWay}">
<Slider.Resources>
<!-- Thumb (the "ball") -->
<StaticResource x:Key="SliderThumbBackground" ResourceKey="DarkGradientBrush"/>
<StaticResource x:Key="SliderThumbBackgroundPointerOver" ResourceKey="DarkGradientBrush"/>
<StaticResource x:Key="SliderThumbBackgroundPressed" ResourceKey="DarkGradientBrush"/>
<!-- Filled portion of the track (defaults to system accent on hover/press) -->
<StaticResource x:Key="SliderTrackValueFill" ResourceKey="SliderGradientBrush"/>
<StaticResource x:Key="SliderTrackValueFillPointerOver" ResourceKey="SliderGradientBrush"/>
<StaticResource x:Key="SliderTrackValueFillPressed" ResourceKey="SliderGradientBrush"/>
</Slider.Resources>
</Slider>
</StackPanel>
</Grid>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -50,6 +50,18 @@ public sealed partial class PlayerView : UserControl
_viewModel.IsPositionChangeInProgress = false; _viewModel.IsPositionChangeInProgress = false;
} }
private void PlayButton_PointerEntered(object? sender, PointerRoutedEventArgs e)
{
PlayHoverExitStoryboard.Stop();
PlayHoverEnterStoryboard.Begin();
}
private void PlayButton_PointerExited(object? sender, PointerRoutedEventArgs e)
{
PlayHoverEnterStoryboard.Stop();
PlayHoverExitStoryboard.Begin();
}
private void VolumeSlider_PointerWheelChanged(object? sender, PointerRoutedEventArgs e) private void VolumeSlider_PointerWheelChanged(object? sender, PointerRoutedEventArgs e)
{ {
if (sender is not Slider slider) if (sender is not Slider slider)

View File

@@ -1,16 +1,20 @@
<UserControl <UserControl
x:Class="Harmonia.WinUI.Views.PlaylistView" x:Class="Harmonia.WinUI.Views.PlaylistDetailView"
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"
xmlns:local="using:Harmonia.WinUI.Views" xmlns:local="using:Harmonia.WinUI.Views"
xmlns:vm="using:Harmonia.WinUI.ViewModels" xmlns:vm="using:Harmonia.WinUI.ViewModels"
xmlns:converter="using:Harmonia.WinUI.Converters"
xmlns:playlists="using:Harmonia.Core.Playlists" xmlns:playlists="using:Harmonia.Core.Playlists"
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"
DataContext="{Binding Source={StaticResource Locator}, Path=PlaylistViewModel}" DataContext="{Binding Source={StaticResource Locator}, Path=PlaylistDetailViewModel}"
d:DataContext="{d:DesignInstance Type=vm:PlaylistViewModel, IsDesignTimeCreatable=True}" d:DataContext="{d:DesignInstance Type=vm:PlaylistDetailViewModel, IsDesignTimeCreatable=True}"
mc:Ignorable="d"> mc:Ignorable="d">
<UserControl.Resources> <UserControl.Resources>
<converter:BooleanToVisibilityConverter x:Key="BoolToVis" />
<converter:BooleanToVisibilityConverter x:Key="InverseBoolToVis" TrueValue="Collapsed" FalseValue="Visible"/>
<!--<SolidColorBrush x:Key="PlaylistBackground" Color="#393a45"/>--> <!--<SolidColorBrush x:Key="PlaylistBackground" Color="#393a45"/>-->
<SolidColorBrush x:Key="PlaylistBackground" Color="#292a35"/> <SolidColorBrush x:Key="PlaylistBackground" Color="#292a35"/>
<SolidColorBrush x:Key="PlaylistItemHighlightColor" Color="#494a55"/> <SolidColorBrush x:Key="PlaylistItemHighlightColor" Color="#494a55"/>
@@ -24,6 +28,11 @@
<SolidColorBrush x:Key="SongItemFooterBrush" Color="#888"/> <SolidColorBrush x:Key="SongItemFooterBrush" Color="#888"/>
<SolidColorBrush x:Key="SongItemFooterBrushHighlighted" Color="#aaA2D2F6"/> <SolidColorBrush x:Key="SongItemFooterBrushHighlighted" Color="#aaA2D2F6"/>
<LinearGradientBrush x:Key="DarkBackgroundBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#101114" Offset="0"/>
<GradientStop Color="#090B0F" Offset="1"/>
</LinearGradientBrush>
<!-- Image Border --> <!-- Image Border -->
<Style x:Key="PlaylistSongImageBorder" TargetType="Border"> <Style x:Key="PlaylistSongImageBorder" TargetType="Border">
<Setter Property="Width" Value="60"/> <!-- as 75 --> <Setter Property="Width" Value="60"/> <!-- as 75 -->
@@ -34,6 +43,7 @@
<!-- Song Item Title Text Block --> <!-- Song Item Title Text Block -->
<Style x:Key="SongTitleTextBlock" TargetType="TextBlock"> <Style x:Key="SongTitleTextBlock" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource SongItemTitleBrush}"/> <Setter Property="Foreground" Value="{StaticResource SongItemTitleBrush}"/>
<Setter Property="FontFamily" Value="{StaticResource AppFontFamilyMedium}"/>
<Setter Property="FontSize" Value="15"/> <Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="Medium"/> <Setter Property="FontWeight" Value="Medium"/>
<Setter Property="LineStackingStrategy" Value="BlockLineHeight"/> <Setter Property="LineStackingStrategy" Value="BlockLineHeight"/>
@@ -67,10 +77,11 @@
<ColumnDefinition Width="Auto"></ColumnDefinition> <ColumnDefinition Width="Auto"></ColumnDefinition>
<ColumnDefinition Width="*"></ColumnDefinition> <ColumnDefinition Width="*"></ColumnDefinition>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource PlaylistSongImageBorder}"> <Border Grid.Column="0" Style="{StaticResource PlaylistSongImageBorder}" BorderBrush="#2D2930" BorderThickness="1">
<Grid> <Grid>
<Image Loaded="Image_Loaded" Unloaded="Image_Unloaded"></Image> <Image Loaded="Image_Loaded" Unloaded="Image_Unloaded"></Image>
<Canvas Background="#19000000"></Canvas> <Canvas Background="#19000000"></Canvas>
<!--<Canvas Background="#30000000"></Canvas>-->
</Grid> </Grid>
</Border> </Border>
<Grid Grid.Column="1" Margin="10 0 0 0" VerticalAlignment="Center"> <Grid Grid.Column="1" Margin="10 0 0 0" VerticalAlignment="Center">
@@ -99,7 +110,7 @@
</Border> </Border>
</DataTemplate> </DataTemplate>
</UserControl.Resources> </UserControl.Resources>
<Grid> <Grid Background="{StaticResource DarkBackgroundBrush}">
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"></RowDefinition> <RowDefinition Height="Auto"></RowDefinition>
<RowDefinition Height="*"></RowDefinition> <RowDefinition Height="*"></RowDefinition>
@@ -123,6 +134,11 @@
<Setter Property="Background" Value="Transparent"></Setter> <Setter Property="Background" Value="Transparent"></Setter>
</Style> </Style>
</MenuFlyout.MenuFlyoutPresenterStyle> </MenuFlyout.MenuFlyoutPresenterStyle>
<MenuFlyoutItem Text="New Playlist" Command="{Binding NewPlaylistCommand}">
<MenuFlyoutItem.Icon>
<PathIcon Data="{StaticResource AddFileIcon}"></PathIcon>
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem Text="Add Files..." Command="{Binding AddFilesCommand}"> <MenuFlyoutItem Text="Add Files..." Command="{Binding AddFilesCommand}">
<MenuFlyoutItem.Icon> <MenuFlyoutItem.Icon>
<PathIcon Data="{StaticResource AddFileIcon}"></PathIcon> <PathIcon Data="{StaticResource AddFileIcon}"></PathIcon>
@@ -155,28 +171,51 @@
</Style> </Style>
</MenuFlyout.MenuFlyoutPresenterStyle> </MenuFlyout.MenuFlyoutPresenterStyle>
<MenuFlyoutItem Text="Refresh Tags" Command="{Binding RefreshTagsCommand}"> <MenuFlyoutItem Text="Refresh Tags" Command="{Binding RefreshTagsCommand}">
</MenuFlyoutItem> </MenuFlyoutItem>
<MenuFlyoutItem Text="Remove Duplicates" Command="{Binding RemoveDuplicateSongsCommand}"> <MenuFlyoutItem Text="Remove Duplicates" Command="{Binding RemoveDuplicateSongsCommand}">
</MenuFlyoutItem> </MenuFlyoutItem>
<MenuFlyoutItem Text="Remove Missing" Command="{Binding RemoveMissingSongsCommand}"> <MenuFlyoutItem Text="Remove Missing" Command="{Binding RemoveMissingSongsCommand}">
</MenuFlyoutItem> </MenuFlyoutItem>
<MenuFlyoutItem Text="Lock Playlist">
<MenuFlyoutItem Text="Rename Playlist" Command="{Binding RenamePlaylistCommand}">
</MenuFlyoutItem> </MenuFlyoutItem>
<MenuFlyoutItem Text="Lock Playlist" Command="{Binding LockPlaylistCommand}" Visibility="{Binding IsPlaylistLocked, Converter={StaticResource InverseBoolToVis}}">
</MenuFlyoutItem>
<MenuFlyoutItem Text="Unlock Playlist" Command="{Binding UnlockPlaylistCommand}" Visibility="{Binding IsPlaylistLocked, Converter={StaticResource BoolToVis}}">
</MenuFlyoutItem>
<MenuFlyoutSeparator></MenuFlyoutSeparator> <MenuFlyoutSeparator></MenuFlyoutSeparator>
<MenuFlyoutItem Text="Remove Playlist" Foreground="#ff99a4">
<MenuFlyoutItem Text="Remove Playlist" Foreground="#ff99a4" Command="{Binding DeletePlaylistCommand}">
</MenuFlyoutItem> </MenuFlyoutItem>
<MenuFlyoutSeparator></MenuFlyoutSeparator> <MenuFlyoutSeparator></MenuFlyoutSeparator>
<MenuFlyoutSubItem x:Name="SortByMenuFlyoutSubItem" Text="Sort By...">
<MenuFlyoutSeparator></MenuFlyoutSeparator>
<MenuFlyoutItem Text="Randomize" Command="{Binding RandomizeAllCommand}">
</MenuFlyoutItem>
<MenuFlyoutItem Text="Reverse" Command="{Binding ReverseAllCommand}">
</MenuFlyoutItem>
</MenuFlyoutSubItem>
<MenuFlyoutSeparator></MenuFlyoutSeparator>
<MenuFlyoutItem Text="Settings"> <MenuFlyoutItem Text="Settings">
</MenuFlyoutItem> </MenuFlyoutItem>
</MenuFlyout> </MenuFlyout>
</Button.Flyout> </Button.Flyout>
</Button> </Button>
<!--<Button Style="{StaticResource PremiumButton-Teal}">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Style="{StaticResource FlatButtonIcon}" Foreground="{StaticResource PremiumTeaForegroundBrush}" Data="{StaticResource AddIcon}" />
<TextBlock Text="Go Premium"/>
</StackPanel>
</Button>-->
</StackPanel> </StackPanel>
</Grid> </Grid>
<ListView <ListView
@@ -184,11 +223,11 @@
Name="PlaylistListView" Name="PlaylistListView"
ItemsSource="{Binding FilteredPlaylistSongs}" ItemsSource="{Binding FilteredPlaylistSongs}"
ItemTemplate="{StaticResource SongTemplate}" ItemTemplate="{StaticResource SongTemplate}"
CanReorderItems="True" CanReorderItems="{Binding CanReorderSongs, Mode=OneWay}"
CanDragItems="True" CanDragItems="{Binding CanReorderSongs, Mode=OneWay}"
DragItemsStarting="PlaylistListView_DragItemsStarting" DragItemsStarting="PlaylistListView_DragItemsStarting"
DragItemsCompleted="PlaylistListView_DragItemsCompleted" DragItemsCompleted="PlaylistListView_DragItemsCompleted"
AllowDrop="True" AllowDrop="{Binding CanReorderSongs, Mode=OneWay}"
SelectionMode="Extended" SelectionMode="Extended"
SelectionChanged="PlaylistListView_SelectionChanged"> SelectionChanged="PlaylistListView_SelectionChanged">
<ListView.ContextFlyout> <ListView.ContextFlyout>
@@ -199,7 +238,7 @@
<Setter Property="Background" Value="Transparent"></Setter> <Setter Property="Background" Value="Transparent"></Setter>
</Style> </Style>
</MenuFlyout.MenuFlyoutPresenterStyle> </MenuFlyout.MenuFlyoutPresenterStyle>
<MenuFlyoutItem Text="Play" FontWeight="SemiBold" Command="{Binding PlaySongCommand}"> <MenuFlyoutItem Text="Play" FontFamily="{StaticResource AppFontFamilySemiBold}" FontWeight="SemiBold" Command="{Binding PlaySongCommand}">
<MenuFlyoutItem.Icon> <MenuFlyoutItem.Icon>
<PathIcon Data="{StaticResource PlayIcon}"></PathIcon> <PathIcon Data="{StaticResource PlayIcon}"></PathIcon>
</MenuFlyoutItem.Icon> </MenuFlyoutItem.Icon>
@@ -217,6 +256,17 @@
</MenuFlyoutItem.KeyboardAccelerators> </MenuFlyoutItem.KeyboardAccelerators>
</MenuFlyoutItem> </MenuFlyoutItem>
<MenuFlyoutSeparator></MenuFlyoutSeparator> <MenuFlyoutSeparator></MenuFlyoutSeparator>
<MenuFlyoutSubItem x:Name="SortSelectedByMenuFlyoutSubItem" Text="Sort By...">
<MenuFlyoutSubItem.Icon>
<PathIcon Data="{StaticResource SortIcon}"></PathIcon>
</MenuFlyoutSubItem.Icon>
<MenuFlyoutSeparator></MenuFlyoutSeparator>
<MenuFlyoutItem Text="Randomize" Command="{Binding RandomizeSelectedCommand}">
</MenuFlyoutItem>
<MenuFlyoutItem Text="Reverse" Command="{Binding ReverseSelectedCommand}">
</MenuFlyoutItem>
</MenuFlyoutSubItem>
<MenuFlyoutSeparator></MenuFlyoutSeparator>
<MenuFlyoutItem Text="Cut" Command="{Binding CutSongsCommand}"> <MenuFlyoutItem Text="Cut" Command="{Binding CutSongsCommand}">
<MenuFlyoutItem.Icon> <MenuFlyoutItem.Icon>
<PathIcon Data="{StaticResource CutIcon}"></PathIcon> <PathIcon Data="{StaticResource CutIcon}"></PathIcon>

View File

@@ -1,5 +1,4 @@
using CommunityToolkit.WinUI; using CommunityToolkit.WinUI;
using Harmonia.Core.Imaging;
using Harmonia.Core.Playlists; using Harmonia.Core.Playlists;
using Harmonia.WinUI.ViewModels; using Harmonia.WinUI.ViewModels;
using Microsoft.UI.Dispatching; using Microsoft.UI.Dispatching;
@@ -7,34 +6,66 @@ using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.UI.Xaml.Media.Imaging;
using System; using System;
using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Windows.UI.Popups;
namespace Harmonia.WinUI.Views; namespace Harmonia.WinUI.Views;
public sealed partial class PlaylistView : UserControl public sealed partial class PlaylistDetailView : UserControl
{ {
private readonly PlaylistViewModel _viewModel; private readonly PlaylistDetailViewModel _viewModel;
public PlaylistView() public PlaylistDetailView()
{ {
InitializeComponent(); InitializeComponent();
_viewModel = (PlaylistViewModel)DataContext; _viewModel = (PlaylistDetailViewModel)DataContext;
_viewModel.PropertyChanging += OnViewModelPropertyChanging; _viewModel.PropertyChanging += OnViewModelPropertyChanging;
_viewModel.PropertyChanged += OnViewModelPropertyChanged; _viewModel.PropertyChanged += OnViewModelPropertyChanged;
_viewModel.PlayingSongChangedAutomatically += OnPlayingSongChangedAutomatically; _viewModel.PlayingSongChangedAutomatically += OnPlayingSongChangedAutomatically;
foreach (MenuFlyoutItemBase item in PlaylistListViewMenuFlyout.Items) foreach (MenuFlyoutItemBase item in PlaylistListViewMenuFlyout.Items)
{ {
item.DataContextChanged += Item_DataContextChanged; HookDataContextChanged(item);
} }
int currentSortItemIndex = 0;
foreach (SortItem sortItem in _viewModel.SortItems)
{
SortByMenuFlyoutSubItem.Items.Insert(currentSortItemIndex, new MenuFlyoutItem
{
Text = sortItem.Name,
Command = _viewModel.SortAllCommand,
CommandParameter = sortItem
});
SortSelectedByMenuFlyoutSubItem.Items.Insert(currentSortItemIndex, new MenuFlyoutItem
{
Text = sortItem.Name,
Command = _viewModel.SortSelectedCommand,
CommandParameter = sortItem
});
currentSortItemIndex++;
}
_viewModel.SortSelectedCommand.CanExecuteChanged += (_, _) =>
SortSelectedByMenuFlyoutSubItem.IsEnabled = _viewModel.SortSelectedCommand.CanExecute(null);
SortSelectedByMenuFlyoutSubItem.IsEnabled = _viewModel.SortSelectedCommand.CanExecute(null);
Loaded += OnLoaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
Loaded -= OnLoaded;
// Defer until the ListView has completed its first layout/container realization pass.
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, BringPlayingSongIntoView);
} }
private void OnViewModelPropertyChanging(object? sender, PropertyChangingEventArgs e) private void OnViewModelPropertyChanging(object? sender, PropertyChangingEventArgs e)
@@ -52,11 +83,56 @@ public sealed partial class PlaylistView : UserControl
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{ {
if (e.PropertyName == nameof(_viewModel.PlayingSong)) switch (e.PropertyName)
{ {
ListViewItem listViewItem = (ListViewItem)PlaylistListView.ContainerFromItem(_viewModel.PlayingSong); case nameof(_viewModel.PlayingSong):
ListViewItem listViewItem = (ListViewItem)PlaylistListView.ContainerFromItem(_viewModel.PlayingSong);
UpdateListViewItemStyle(listViewItem, true);
break;
case nameof(_viewModel.Playlist):
ScrollToTop();
break;
case nameof(_viewModel.SelectedPlaylistSongs):
SyncListViewSelection();
break;
}
}
UpdateListViewItemStyle(listViewItem, true); private bool _isSyncingSelection;
private void SyncListViewSelection()
{
_isSyncingSelection = true;
try
{
PlaylistListView.DeselectAll();
foreach (PlaylistSong playlistSong in _viewModel.SelectedPlaylistSongs)
{
int index = PlaylistListView.Items.IndexOf(playlistSong);
if (index >= 0)
{
PlaylistListView.SelectRange(new ItemIndexRange(index, 1));
}
}
}
finally
{
_isSyncingSelection = false;
}
}
private void ScrollToTop()
{
if (PlaylistListView.FindDescendant<ScrollViewer>() is ScrollViewer scrollViewer)
{
scrollViewer.ChangeView(null, 0, null, disableAnimation: false);
}
else if (_viewModel.FilteredPlaylistSongs.FirstOrDefault() is PlaylistSong firstSong)
{
PlaylistListView.ScrollIntoView(firstSong);
} }
} }
@@ -132,6 +208,19 @@ public sealed partial class PlaylistView : UserControl
} }
} }
private void HookDataContextChanged(MenuFlyoutItemBase item)
{
item.DataContextChanged += Item_DataContextChanged;
if (item is MenuFlyoutSubItem subItem)
{
foreach (MenuFlyoutItemBase childItem in subItem.Items)
{
HookDataContextChanged(childItem);
}
}
}
private void Item_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args) private void Item_DataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
{ {
if (sender is not MenuFlyoutItemBase item) if (sender is not MenuFlyoutItemBase item)
@@ -195,7 +284,7 @@ public sealed partial class PlaylistView : UserControl
if (args.NewValue is not PlaylistSong playlistSong) if (args.NewValue is not PlaylistSong playlistSong)
return; return;
bool isPlaying = playlistSong == _viewModel.PlayingSong; bool isPlaying = playlistSong.UID == _viewModel.PlayingSong?.UID;
UpdateListViewItemStyle(sender, isPlaying); UpdateListViewItemStyle(sender, isPlaying);
} }
@@ -230,6 +319,9 @@ public sealed partial class PlaylistView : UserControl
private void PlaylistListView_SelectionChanged(object sender, SelectionChangedEventArgs e) private void PlaylistListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{ {
if (_isSyncingSelection)
return;
if (sender is not ListView listView) if (sender is not ListView listView)
return; return;

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<UserControl
x:Class="Harmonia.WinUI.Views.PlaylistsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Harmonia.WinUI.Views"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Harmonia.WinUI.ViewModels"
xmlns:converter="using:Harmonia.WinUI.Converters"
xmlns:playlists="using:Harmonia.Core.Playlists"
DataContext="{Binding Source={StaticResource Locator}, Path=PlaylistsViewModel}"
d:DataContext="{d:DesignInstance Type=vm:PlaylistsViewModel, IsDesignTimeCreatable=True}"
mc:Ignorable="d">
<UserControl.Resources>
<converter:BooleanToVisibilityConverter x:Key="BoolToVis" />
<converter:BooleanToVisibilityConverter x:Key="InverseBoolToVis" TrueValue="Collapsed" FalseValue="Visible"/>
<SolidColorBrush x:Key="PlaylistItemIconBrush" Color="#dddddd"/>
<SolidColorBrush x:Key="PlaylistItemTitleBrush" Color="#dddddd"/>
<SolidColorBrush x:Key="PlaylistItemSubtitleBrush" Color="#aaaaaa"/>
<LinearGradientBrush x:Key="DarkBackgroundBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#161719" Offset="0"/>
<GradientStop Color="#0B0D0F" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="ActiveGradientBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#E1B88A" Offset="0"/>
<GradientStop Color="#E9B578" Offset="1"/>
</LinearGradientBrush>
<LinearGradientBrush x:Key="ActiveDarkGradientBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#F9EBC6" Offset="0"/>
<GradientStop Color="#A66831" Offset="1"/>
</LinearGradientBrush>
<!-- Playlist Icon Path -->
<Style x:Key="PlaylistIconPath" TargetType="PathIcon">
<Setter Property="Foreground" Value="{StaticResource PlaylistItemIconBrush}"/>
<Setter Property="Foreground" Value="{StaticResource ActiveDarkGradientBrush}"/>
</Style>
<Style x:Key="SelectedPlaylistIconPath" TargetType="PathIcon" BasedOn="{StaticResource PlaylistIconPath}">
<Setter Property="Foreground" Value="{StaticResource AccentTextFillColorPrimaryBrush}"/>
<Setter Property="Foreground" Value="{StaticResource ActiveGradientBrush}"/>
<Setter Property="Foreground" Value="{StaticResource ActiveDarkGradientBrush}"/>
</Style>
<!-- Playlist Title Text Block -->
<Style x:Key="PlaylistTitleTextBlock" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource PlaylistItemTitleBrush}"/>
<Setter Property="FontSize" Value="15"/>
<Setter Property="FontWeight" Value="Normal"/>
<Setter Property="LineStackingStrategy" Value="BlockLineHeight"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
<Setter Property="LineHeight" Value="0"/>
</Style>
<Style x:Key="SelectedPlaylistTitleTextBlock" TargetType="TextBlock" BasedOn="{StaticResource PlaylistTitleTextBlock}">
<Setter Property="Foreground" Value="{StaticResource AccentTextFillColorPrimaryBrush}"/>
<Setter Property="FontFamily" Value="{StaticResource AppFontFamilyMedium}"/>
<Setter Property="FontWeight" Value="Medium"/>
</Style>
<!-- Playlist Item Subtitle Text Block -->
<Style x:Key="PlaylistSubtitleTextBlock" TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource PlaylistItemSubtitleBrush}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="LineStackingStrategy" Value="BlockLineHeight"/>
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
<Setter Property="LineHeight" Value="0"/>
</Style>
<Style x:Key="SelectedPlaylistSubtitleTextBlock" TargetType="TextBlock" BasedOn="{StaticResource PlaylistSubtitleTextBlock}">
<Setter Property="Foreground" Value="{StaticResource AccentTextFillColorSecondaryBrush}"/>
</Style>
<DataTemplate x:Key="PlaylistTemplate" x:DataType="vm:PlaylistItemViewModel">
<Grid x:Name="PlaylistListViewItem">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="200" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Viewbox Height="24" Width="24">
<PathIcon x:Name="MusicNoteListPathIcon" Data="{StaticResource MusicNoteList}" />
</Viewbox>
<TextBlock x:Name="PlaylistTitleTextBlock" Text="{x:Bind Name, Mode=OneWay}" Style="{StaticResource PlaylistTitleTextBlock}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center" Spacing="8">
<TextBlock x:Name="PlaylistSubtitleTextBlock" Text="{x:Bind Summary, Mode=OneWay}" Style="{StaticResource PlaylistSubtitleTextBlock}" />
</StackPanel>
</Grid>
</DataTemplate>
</UserControl.Resources>
<Grid Background="{StaticResource DarkBackgroundBrush}" BorderBrush="#3C342F" BorderThickness="1 0 1 0">
<ListView
Name="PlaylistsListView"
ItemsSource="{Binding Playlists, Mode=OneWay}"
SelectedItem="{Binding SelectedPlaylist, Mode=TwoWay}"
ItemTemplate="{StaticResource PlaylistTemplate}"
RightTapped="PlaylistsListView_RightTapped">
<ListView.ContextFlyout>
<MenuFlyout
x:Name="PlaylistListViewMenuFlyout"
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="{Binding FlyoutPlaylist.Name}" IsEnabled="False" FontWeight="SemiLight">
<MenuFlyoutItem.Resources>
<SolidColorBrush x:Key="MenuFlyoutItemForegroundDisabled" Color="#aaaaaa"/>
</MenuFlyoutItem.Resources>
</MenuFlyoutItem>
<MenuFlyoutSeparator></MenuFlyoutSeparator>
<MenuFlyoutItem Text="Rename" Command="{Binding RenamePlaylistCommand}" CommandParameter="{Binding FlyoutPlaylist}">
</MenuFlyoutItem>
<MenuFlyoutItem Text="Lock" Command="{Binding LockPlaylistCommand}" CommandParameter="{Binding FlyoutPlaylist}" Visibility="{Binding FlyoutPlaylist.IsLocked, Converter={StaticResource InverseBoolToVis}}">
</MenuFlyoutItem>
<MenuFlyoutItem Text="Unlock" Command="{Binding UnlockPlaylistCommand}" CommandParameter="{Binding FlyoutPlaylist}" Visibility="{Binding FlyoutPlaylist.IsLocked, Converter={StaticResource BoolToVis}}">
</MenuFlyoutItem>
<MenuFlyoutSeparator></MenuFlyoutSeparator>
<MenuFlyoutItem Text="Remove" Foreground="#ff99a4" Command="{Binding DeletePlaylistCommand}" CommandParameter="{Binding FlyoutPlaylist}">
</MenuFlyoutItem>
</MenuFlyout>
</ListView.ContextFlyout>
</ListView>
</Grid>
</UserControl>

View File

@@ -0,0 +1,126 @@
using CommunityToolkit.WinUI;
using Harmonia.WinUI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.ComponentModel;
namespace Harmonia.WinUI.Views;
public sealed partial class PlaylistsView : UserControl
{
private readonly PlaylistsViewModel _viewModel;
public PlaylistsView()
{
InitializeComponent();
_viewModel = (PlaylistsViewModel)DataContext;
_viewModel.PropertyChanging += OnViewModelPropertyChanging;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
PlaylistsListView.ContainerContentChanging += OnContainerContentChanging;
foreach (MenuFlyoutItemBase item in PlaylistListViewMenuFlyout.Items)
{
item.DataContext = _viewModel;
}
}
private void PlaylistsListView_RightTapped(object sender, Microsoft.UI.Xaml.Input.RightTappedRoutedEventArgs e)
{
_viewModel.FlyoutPlaylist = (e.OriginalSource as FrameworkElement)?.DataContext as PlaylistItemViewModel;
}
private void OnContainerContentChanging(ListViewBase sender, ContainerContentChangingEventArgs args)
{
if (args.InRecycleQueue || args.ItemContainer is not ListViewItem listViewItem)
return;
PlaylistItemStyle style = ReferenceEquals(args.Item, _viewModel.ActivePlaylist)
? PlaylistItemStyle.Selected
: PlaylistItemStyle.Normal;
UpdateListViewItemStyle(listViewItem, style);
}
private void OnViewModelPropertyChanging(object? sender, PropertyChangingEventArgs e)
{
switch (e.PropertyName)
{
case nameof(_viewModel.ActivePlaylist):
UpdateListViewItemStyle(PlaylistItemStyle.Normal);
break;
}
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(_viewModel.ActivePlaylist):
UpdateListViewItemStyle(PlaylistItemStyle.Selected);
break;
}
}
private void UpdateListViewItemStyle(PlaylistItemStyle style)
{
if (PlaylistsListView.ContainerFromItem(_viewModel.ActivePlaylist) is not ListViewItem listViewItem)
return;
UpdateListViewItemStyle(listViewItem, style);
}
private void UpdateListViewItemStyle(ListViewItem listViewItem, PlaylistItemStyle style)
{
FrameworkElement? frameworkElement = TryGetFrameworkElement(listViewItem, "PlaylistListViewItem");
if (frameworkElement == null)
return;
string playlistItemIconBrushName = style == PlaylistItemStyle.Selected ? "SelectedPlaylistIconPath" : "PlaylistIconPath";
string playlistItemTitleBrushName = style == PlaylistItemStyle.Selected ? "SelectedPlaylistTitleTextBlock" : "PlaylistTitleTextBlock";
string playlistItemSubtitleBrushName = style == PlaylistItemStyle.Selected ? "SelectedPlaylistSubtitleTextBlock" : "PlaylistSubtitleTextBlock";
UpdateElementStyle(frameworkElement, "MusicNoteListPathIcon", playlistItemIconBrushName);
UpdateElementStyle(frameworkElement, "PlaylistTitleTextBlock", playlistItemTitleBrushName);
UpdateElementStyle(frameworkElement, "PlaylistSubtitleTextBlock", playlistItemSubtitleBrushName);
}
private static FrameworkElement? TryGetFrameworkElement(ListViewItem listViewItem, string elementName)
{
if (listViewItem.Name == elementName)
{
return listViewItem;
}
else
{
if (listViewItem.FindDescendant(elementName) is not FrameworkElement frameworkElement)
return null;
return frameworkElement;
}
}
private void UpdateElementStyle(FrameworkElement dependencyObject, string elementName, string resourceName)
{
if (dependencyObject.FindDescendant(elementName) is not FrameworkElement frameworkElement)
return;
Resources.TryGetValue(resourceName, out object? resource);
if (resource == null)
Application.Current.Resources.TryGetValue(resourceName, out resource);
if (resource is not Style style)
return;
frameworkElement.Style = style;
}
}
public enum PlaylistItemStyle
{
Normal,
Selected
}