diff --git a/Screenbox.Core/Models/PersistentPlaylist.cs b/Screenbox.Core/Models/PersistentPlaylist.cs new file mode 100644 index 000000000..474b43da8 --- /dev/null +++ b/Screenbox.Core/Models/PersistentPlaylist.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using Screenbox.Core.Models; + +namespace Screenbox.Core.Models; + +public class PersistentPlaylist +{ + public string Id { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public DateTimeOffset Created { get; set; } + public List Items { get; set; } = new(); +} diff --git a/Screenbox.Core/Screenbox.Core.csproj b/Screenbox.Core/Screenbox.Core.csproj index 4d7cd515e..87daabe22 100644 --- a/Screenbox.Core/Screenbox.Core.csproj +++ b/Screenbox.Core/Screenbox.Core.csproj @@ -274,6 +274,7 @@ + diff --git a/Screenbox.Core/Services/PlaylistService.cs b/Screenbox.Core/Services/PlaylistService.cs new file mode 100644 index 000000000..2480dea68 --- /dev/null +++ b/Screenbox.Core/Services/PlaylistService.cs @@ -0,0 +1,104 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Windows.Storage; +using Screenbox.Core.ViewModels; +using Screenbox.Core.Models; + +namespace Screenbox.Core.Services; + +public sealed class PlaylistService +{ + private const string PlaylistsFolderName = "Playlists"; + private const string ThumbnailsFolderName = "Thumbnails"; + private readonly FilesService _filesService; + + public PlaylistService(FilesService filesService) + { + _filesService = filesService; + } + + public async Task SavePlaylistAsync(PersistentPlaylist playlist) + { + StorageFolder playlistsFolder = await ApplicationData.Current.LocalFolder.CreateFolderAsync(PlaylistsFolderName, CreationCollisionOption.OpenIfExists); + string fileName = playlist.Id + ".json"; + await _filesService.SaveToDiskAsync(playlistsFolder, fileName, playlist); + } + + public async Task LoadPlaylistAsync(string id) + { + StorageFolder playlistsFolder = await ApplicationData.Current.LocalFolder.CreateFolderAsync(PlaylistsFolderName, CreationCollisionOption.OpenIfExists); + string fileName = id + ".json"; + try + { + return await _filesService.LoadFromDiskAsync(playlistsFolder, fileName); + } + catch + { + return null; + } + } + + public async Task> ListPlaylistsAsync() + { + StorageFolder playlistsFolder = await ApplicationData.Current.LocalFolder.CreateFolderAsync(PlaylistsFolderName, CreationCollisionOption.OpenIfExists); + var files = await playlistsFolder.GetFilesAsync(); + var playlists = new List(); + foreach (var file in files) + { + try + { + var playlist = await _filesService.LoadFromDiskAsync(file); + if (playlist != null) + playlists.Add(playlist); + } + catch { } + } + return playlists; + } + + public async Task DeletePlaylistAsync(string id) + { + StorageFolder playlistsFolder = await ApplicationData.Current.LocalFolder.CreateFolderAsync(PlaylistsFolderName, CreationCollisionOption.OpenIfExists); + string fileName = id + ".json"; + try + { + StorageFile file = await playlistsFolder.GetFileAsync(fileName); + await file.DeleteAsync(); + } + catch { } + } + + public async Task SaveThumbnailAsync(string mediaLocation, byte[] imageBytes) + { + StorageFolder thumbnailsFolder = await ApplicationData.Current.LocalCacheFolder.CreateFolderAsync(ThumbnailsFolderName, CreationCollisionOption.OpenIfExists); + string hash = GetHash(mediaLocation); + StorageFile file = await thumbnailsFolder.CreateFileAsync(hash + ".png", CreationCollisionOption.ReplaceExisting); + await FileIO.WriteBytesAsync(file, imageBytes); + } + + public async Task GetThumbnailFileAsync(string mediaLocation) + { + StorageFolder thumbnailsFolder = await ApplicationData.Current.LocalCacheFolder.CreateFolderAsync(ThumbnailsFolderName, CreationCollisionOption.OpenIfExists); + string hash = GetHash(mediaLocation); + try + { + return await thumbnailsFolder.GetFileAsync(hash + ".png"); + } + catch + { + return null; + } + } + + private static string GetHash(string input) + { + using var sha256 = System.Security.Cryptography.SHA256.Create(); + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input.ToLowerInvariant()); + byte[] hashBytes = sha256.ComputeHash(bytes); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } +} diff --git a/Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs b/Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs new file mode 100644 index 000000000..46d3befd8 --- /dev/null +++ b/Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs @@ -0,0 +1,72 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Screenbox.Core.Models; +using Screenbox.Core.Services; +using System.Collections.ObjectModel; +using System.Threading.Tasks; + +namespace Screenbox.Core.ViewModels; + +public partial class PlaylistsPageViewModel : ObservableObject +{ + private readonly PlaylistService _playlistService; + + [ObservableProperty] + private ObservableCollection _playlists = new(); + + [ObservableProperty] + private PersistentPlaylist? _selectedPlaylist; + + public PlaylistsPageViewModel(PlaylistService playlistService) + { + _playlistService = playlistService; + } + + [RelayCommand] + public async Task LoadPlaylistsAsync() + { + var loaded = await _playlistService.ListPlaylistsAsync(); + Playlists.Clear(); + foreach (var p in loaded) + Playlists.Add(p); + } + + [RelayCommand] + public async Task CreatePlaylistAsync() + { + // UI modal should collect display name and items, then call this command + var playlist = new PersistentPlaylist + { + Id = System.Guid.NewGuid().ToString(), + DisplayName = string.Empty, // To be set by modal + Created = System.DateTimeOffset.Now, + Items = new() + }; + await _playlistService.SavePlaylistAsync(playlist); + Playlists.Add(playlist); + SelectedPlaylist = playlist; + } + + + [RelayCommand] + public async Task RenamePlaylistAsync(PersistentPlaylist playlist, string newName) + { + playlist.DisplayName = newName; + await _playlistService.SavePlaylistAsync(playlist); + } + + [RelayCommand] + public async Task DeletePlaylistAsync(PersistentPlaylist playlist) + { + await _playlistService.DeletePlaylistAsync(playlist.Id); + Playlists.Remove(playlist); + if (SelectedPlaylist == playlist) + SelectedPlaylist = null; + } + + [RelayCommand] + public void SelectPlaylist(PersistentPlaylist playlist) + { + SelectedPlaylist = playlist; + } +} diff --git a/Screenbox/App.xaml b/Screenbox/App.xaml index 12891e0db..242cf1ed9 100644 --- a/Screenbox/App.xaml +++ b/Screenbox/App.xaml @@ -89,6 +89,7 @@ 0,12,0,0 0,16,0,0 0,0,0,12 + 0,0,0,16 0,0,0,100 0,0,0,106 diff --git a/Screenbox/App.xaml.cs b/Screenbox/App.xaml.cs index b233ebe2a..e8b1b33ac 100644 --- a/Screenbox/App.xaml.cs +++ b/Screenbox/App.xaml.cs @@ -119,6 +119,7 @@ private static IServiceProvider ConfigureServices() services.AddSingleton(); services.AddSingleton(_ => new NavigationService( new KeyValuePair(typeof(HomePageViewModel), typeof(HomePage)), + new KeyValuePair(typeof(PlaylistsPageViewModel), typeof(PlaylistsPage)), new KeyValuePair(typeof(VideosPageViewModel), typeof(VideosPage)), new KeyValuePair(typeof(AllVideosPageViewModel), typeof(AllVideosPage)), new KeyValuePair(typeof(MusicPageViewModel), typeof(MusicPage)), diff --git a/Screenbox/Pages/MainPage.xaml b/Screenbox/Pages/MainPage.xaml index 716730ce1..3d79b8a73 100644 --- a/Screenbox/Pages/MainPage.xaml +++ b/Screenbox/Pages/MainPage.xaml @@ -16,6 +16,7 @@ xmlns:muxc="using:Microsoft.UI.Xaml.Controls" xmlns:strings="using:Screenbox.Strings" xmlns:triggers="using:Screenbox.Triggers" + xmlns:ui="using:CommunityToolkit.WinUI" muxc:BackdropMaterial.ApplyToRootOrPageBackground="True" Loaded="MainPage_Loaded" mc:Ignorable="d"> @@ -264,6 +265,9 @@ + + + diff --git a/Screenbox/Pages/MainPage.xaml.cs b/Screenbox/Pages/MainPage.xaml.cs index 154d9886a..bd7d7396c 100644 --- a/Screenbox/Pages/MainPage.xaml.cs +++ b/Screenbox/Pages/MainPage.xaml.cs @@ -60,6 +60,7 @@ public MainPage() { "music", typeof(MusicPage) }, { "queue", typeof(PlayQueuePage) }, { "network", typeof(NetworkPage) }, + { "playlists", typeof(PlaylistsPage) }, { "settings", typeof(SettingsPage) } }; diff --git a/Screenbox/Pages/PlaylistsPage.xaml b/Screenbox/Pages/PlaylistsPage.xaml new file mode 100644 index 000000000..c44486b4a --- /dev/null +++ b/Screenbox/Pages/PlaylistsPage.xaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + diff --git a/Screenbox/Pages/PlaylistsPage.xaml.cs b/Screenbox/Pages/PlaylistsPage.xaml.cs new file mode 100644 index 000000000..e12deab10 --- /dev/null +++ b/Screenbox/Pages/PlaylistsPage.xaml.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Foundation.Collections; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Controls.Primitives; +using Windows.UI.Xaml.Data; +using Windows.UI.Xaml.Input; +using Windows.UI.Xaml.Media; +using Windows.UI.Xaml.Navigation; + +// The Blank Page item template is documented at https://go.microsoft.com/fwlink/?LinkId=234238 + +namespace Screenbox.Pages; +/// +/// An empty page that can be used on its own or navigated to within a Frame. +/// +public sealed partial class PlaylistsPage : Page +{ + public PlaylistsPage() + { + this.InitializeComponent(); + } +} diff --git a/Screenbox/Screenbox.csproj b/Screenbox/Screenbox.csproj index fb0e72c58..675ab35b9 100644 --- a/Screenbox/Screenbox.csproj +++ b/Screenbox/Screenbox.csproj @@ -249,6 +249,9 @@ AlbumDetailsPage.xaml + + PlaylistsPage.xaml + AlbumSearchResultPage.xaml @@ -622,6 +625,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + Designer MSBuild:Compile