diff --git a/Screenbox.Core/Common/ServiceHelpers.cs b/Screenbox.Core/Common/ServiceHelpers.cs index bd8127e2e..9e7adcd0d 100644 --- a/Screenbox.Core/Common/ServiceHelpers.cs +++ b/Screenbox.Core/Common/ServiceHelpers.cs @@ -31,6 +31,7 @@ public static void PopulateCoreServices(ServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -41,6 +42,8 @@ public static void PopulateCoreServices(ServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddSingleton(); // Shared between many pages services.AddSingleton(); // Avoid thread lock services.AddSingleton(); // Global playlist @@ -57,6 +60,7 @@ public static void PopulateCoreServices(ServiceCollection services) // Contexts services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Screenbox.Core/Contexts/PlaylistsContext.cs b/Screenbox.Core/Contexts/PlaylistsContext.cs new file mode 100644 index 000000000..27f402384 --- /dev/null +++ b/Screenbox.Core/Contexts/PlaylistsContext.cs @@ -0,0 +1,18 @@ +#nullable enable + +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Screenbox.Core.ViewModels; + +namespace Screenbox.Core.Contexts; + +/// +/// Context for holding the application-wide playlists. +/// +public sealed partial class PlaylistsContext : ObservableObject +{ + /// + /// Gets the collection of playlists. + /// + public ObservableCollection Playlists { get; } = new(); +} diff --git a/Screenbox.Core/Models/MediaInfo.cs b/Screenbox.Core/Models/MediaInfo.cs index 0661863e3..87baae07c 100644 --- a/Screenbox.Core/Models/MediaInfo.cs +++ b/Screenbox.Core/Models/MediaInfo.cs @@ -1,10 +1,11 @@ #nullable enable -using Screenbox.Core.Enums; using System; +using Screenbox.Core.Enums; using Windows.Storage.FileProperties; namespace Screenbox.Core.Models; + public sealed class MediaInfo { public MediaPlaybackType MediaType { get; set; } @@ -17,11 +18,18 @@ public sealed class MediaInfo public DateTimeOffset DateModified { get; } - public MediaInfo(MediaPlaybackType mediaType) + public MediaInfo(MediaPlaybackType mediaType, string title = "", uint year = default, TimeSpan duration = default) { MediaType = mediaType; VideoProperties = new VideoInfo(); MusicProperties = new MusicInfo(); + + VideoProperties.Title = title; + VideoProperties.Duration = duration; + VideoProperties.Year = year; + MusicProperties.Title = title; + MusicProperties.Duration = duration; + MusicProperties.Year = year; } internal MediaInfo(IMediaProperties properties) diff --git a/Screenbox.Core/Models/PersistentMediaRecord.cs b/Screenbox.Core/Models/PersistentMediaRecord.cs index ace4b5f8a..f6de12c31 100644 --- a/Screenbox.Core/Models/PersistentMediaRecord.cs +++ b/Screenbox.Core/Models/PersistentMediaRecord.cs @@ -1,7 +1,9 @@ #nullable enable using System; +using System.Text.Json.Serialization; using ProtoBuf; +using Screenbox.Core.Enums; namespace Screenbox.Core.Models; @@ -14,12 +16,23 @@ public class PersistentMediaRecord [ProtoMember(2)] public string Path { get; set; } + [JsonIgnore] [ProtoMember(3)] - public IMediaProperties Properties { get; set; } + public IMediaProperties? Properties { get; set; } [ProtoMember(4)] public DateTime DateAdded { get; set; } // Must be UTC + [ProtoMember(5)] + public TimeSpan Duration { get; set; } + + [ProtoMember(6)] + public uint Year { get; set; } + + [ProtoMember(7)] + public MediaPlaybackType MediaType { get; set; } + + #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. public PersistentMediaRecord() { @@ -31,7 +44,14 @@ public PersistentMediaRecord(string title, string path, IMediaProperties propert { Title = title; Path = path; - Properties = properties; DateAdded = dateAdded.UtcDateTime; + Duration = properties.Duration; + Year = properties.Year; + MediaType = properties switch + { + VideoInfo => MediaPlaybackType.Video, + MusicInfo => MediaPlaybackType.Music, + _ => MediaPlaybackType.Unknown, + }; } } diff --git a/Screenbox.Core/Models/PersistentPlaylist.cs b/Screenbox.Core/Models/PersistentPlaylist.cs new file mode 100644 index 000000000..db51810b7 --- /dev/null +++ b/Screenbox.Core/Models/PersistentPlaylist.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; + +namespace Screenbox.Core.Models; + +public class PersistentPlaylist +{ + public string Id { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public DateTimeOffset LastUpdated { get; set; } + public List Items { get; set; } = new(); +} diff --git a/Screenbox.Core/Models/VideoInfo.cs b/Screenbox.Core/Models/VideoInfo.cs index 6b5a14b17..adc67913a 100644 --- a/Screenbox.Core/Models/VideoInfo.cs +++ b/Screenbox.Core/Models/VideoInfo.cs @@ -1,8 +1,9 @@ -using ProtoBuf; -using System; +using System; +using ProtoBuf; using Windows.Storage.FileProperties; namespace Screenbox.Core.Models; + [ProtoContract] public sealed class VideoInfo : IMediaProperties { diff --git a/Screenbox.Core/Screenbox.Core.csproj b/Screenbox.Core/Screenbox.Core.csproj index c51129997..97a76f008 100644 --- a/Screenbox.Core/Screenbox.Core.csproj +++ b/Screenbox.Core/Screenbox.Core.csproj @@ -129,6 +129,7 @@ + @@ -203,6 +204,7 @@ + @@ -288,7 +290,10 @@ + + + diff --git a/Screenbox.Core/Services/FilesService.cs b/Screenbox.Core/Services/FilesService.cs index 59dfe4f4a..9c59e0dd6 100644 --- a/Screenbox.Core/Services/FilesService.cs +++ b/Screenbox.Core/Services/FilesService.cs @@ -1,14 +1,15 @@ #nullable enable -using ProtoBuf; -using Screenbox.Core.Enums; -using Screenbox.Core.Helpers; -using Screenbox.Core.Models; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; +using ProtoBuf; +using Screenbox.Core.Enums; +using Screenbox.Core.Helpers; +using Screenbox.Core.Models; using Windows.Foundation; using Windows.Storage; using Windows.Storage.AccessCache; @@ -112,14 +113,19 @@ public async Task SaveToDiskAsync(StorageFolder folder, string f public async Task SaveToDiskAsync(StorageFile file, T source) { - using var stream = await file.OpenStreamForWriteAsync(); - // using var dataWriter = new StreamWriter(stream); - // using var jsonWriter = new JsonTextWriter(dataWriter); - // var serializer = JsonSerializer.Create(); - // serializer.Serialize(jsonWriter, source); - Serializer.Serialize(stream, source); - stream.SetLength(stream.Position); // A weird quirk of protobuf-net - await stream.FlushAsync(); + if (file.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + var json = JsonSerializer.Serialize(source); + await FileIO.WriteTextAsync(file, json); + } + else + { + using var stream = await file.OpenAsync(FileAccessMode.ReadWrite); + var writeStream = stream.AsStreamForWrite(); + Serializer.Serialize(writeStream, source); + writeStream.SetLength(writeStream.Position); // A weird quirk of protobuf-net + await stream.FlushAsync(); + } } public async Task LoadFromDiskAsync(StorageFolder folder, string fileName) @@ -130,12 +136,14 @@ public async Task LoadFromDiskAsync(StorageFolder folder, string fileName) public async Task LoadFromDiskAsync(StorageFile file) { - using Stream readStream = await file.OpenStreamForReadAsync(); - return Serializer.Deserialize(readStream); - // using var dataReader = new StreamReader(readStream); - // using var jsonReader = new JsonTextReader(dataReader); - // var serializer = JsonSerializer.Create(); - // return serializer.Deserialize(jsonReader) ?? throw new NullReferenceException(); + if (file.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + string json = await FileIO.ReadTextAsync(file); + return JsonSerializer.Deserialize(json) ?? throw new InvalidOperationException("Failed to deserialize JSON"); + } + + using var readStream = await file.OpenReadAsync(); + return Serializer.Deserialize(readStream.AsStream()); } public async Task OpenFileLocationAsync(string path) diff --git a/Screenbox.Core/Services/IPlaylistService.cs b/Screenbox.Core/Services/IPlaylistService.cs index 30a3e463a..79c8503d0 100644 --- a/Screenbox.Core/Services/IPlaylistService.cs +++ b/Screenbox.Core/Services/IPlaylistService.cs @@ -4,7 +4,9 @@ using System.Threading; using System.Threading.Tasks; using Screenbox.Core.Models; +using Screenbox.Core.ViewModels; using Windows.Media; +using Windows.Storage; using Windows.Storage.Search; namespace Screenbox.Core.Services; @@ -33,4 +35,39 @@ public interface IPlaylistService /// Get media buffer indices around current position /// IReadOnlyList GetMediaBufferIndices(int currentIndex, int playlistCount, MediaPlaybackAutoRepeatMode repeatMode, int bufferSize = 5); + + /// + /// Save a persistent playlist to storage + /// + Task SavePlaylistAsync(PersistentPlaylist playlist); + + /// + /// Load a persistent playlist from storage + /// + Task LoadPlaylistAsync(string id); + + /// + /// List persistent playlists from storage + /// + Task> ListPlaylistsAsync(); + + /// + /// Delete a persistent playlist from storage + /// + Task DeletePlaylistAsync(string id); + + /// + /// Save a thumbnail for a media item + /// + Task SaveThumbnailAsync(string mediaLocation, byte[] imageBytes); + + /// + /// Get a thumbnail file for a media item + /// + Task GetThumbnailFileAsync(string mediaLocation); + + /// + /// Appends media items to an existing persistent playlist and persists the updated playlist. + /// + Task AddToPlaylistAsync(string playlistId, IReadOnlyList items); } diff --git a/Screenbox.Core/Services/LibraryService.cs b/Screenbox.Core/Services/LibraryService.cs index 343f63e6b..bdd84adfe 100644 --- a/Screenbox.Core/Services/LibraryService.cs +++ b/Screenbox.Core/Services/LibraryService.cs @@ -228,7 +228,9 @@ private List GetMediaFromCache(PersistentStorageLibrary libraryC MediaViewModel media = _mediaFactory.GetSingleton(new Uri(record.Path)); media.IsFromLibrary = true; if (!string.IsNullOrEmpty(record.Title)) media.Name = record.Title; - media.MediaInfo = new MediaInfo(record.Properties); + media.MediaInfo = record.Properties != null + ? new MediaInfo(record.Properties) + : new MediaInfo(record.MediaType, record.Title, record.Year, record.Duration); if (record.DateAdded != default) { DateTimeOffset utcTime = DateTime.SpecifyKind(record.DateAdded, DateTimeKind.Utc); diff --git a/Screenbox.Core/Services/PlaylistService.cs b/Screenbox.Core/Services/PlaylistService.cs index 529c9969b..8b7199f4d 100644 --- a/Screenbox.Core/Services/PlaylistService.cs +++ b/Screenbox.Core/Services/PlaylistService.cs @@ -9,20 +9,23 @@ using Screenbox.Core.Models; using Screenbox.Core.ViewModels; using Windows.Media; +using Windows.Storage; using Windows.Storage.Search; namespace Screenbox.Core.Services; -/// -/// Stateless service for playlist operations -/// public sealed class PlaylistService : IPlaylistService { + private const string PlaylistsFolderName = "Playlists"; + private const string ThumbnailsFolderName = "Thumbnails"; + private readonly IMediaListFactory _mediaListFactory; + private readonly IFilesService _filesService; - public PlaylistService(IMediaListFactory mediaListFactory) + public PlaylistService(IFilesService filesService, IMediaListFactory mediaListFactory) { _mediaListFactory = mediaListFactory; + _filesService = filesService; } public async Task AddNeighboringFilesAsync(Playlist playlist, StorageFileQueryResult neighboringFilesQuery, CancellationToken cancellationToken = default) @@ -121,4 +124,111 @@ private static void Shuffle(IList list, Random rng) (list[k], list[n]) = (list[n], list[k]); } } + + 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(); + } + + public async Task AddToPlaylistAsync(string playlistId, IReadOnlyList items) + { + if (string.IsNullOrWhiteSpace(playlistId)) throw new ArgumentException("Value cannot be null or whitespace.", nameof(playlistId)); + if (items is null) throw new ArgumentNullException(nameof(items)); + if (items.Count == 0) return; + + PersistentPlaylist? playlist = await LoadPlaylistAsync(playlistId); + if (playlist is null) + { + throw new InvalidOperationException($"Playlist '{playlistId}' was not found."); + } + + foreach (MediaViewModel m in items) + { + if (m is null) continue; + IMediaProperties properties = m.MediaType == Screenbox.Core.Enums.MediaPlaybackType.Music + ? m.MediaInfo.MusicProperties + : m.MediaInfo.VideoProperties; + + playlist.Items.Add(new PersistentMediaRecord(m.Name, m.Location, properties, m.DateAdded)); + } + + playlist.LastUpdated = DateTimeOffset.Now; + await SavePlaylistAsync(playlist); + } } diff --git a/Screenbox.Core/ViewModels/CommonViewModel.cs b/Screenbox.Core/ViewModels/CommonViewModel.cs index 163d497e3..414f38c65 100644 --- a/Screenbox.Core/ViewModels/CommonViewModel.cs +++ b/Screenbox.Core/ViewModels/CommonViewModel.cs @@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging.Messages; +using Screenbox.Core.Contexts; using Screenbox.Core.Enums; using Screenbox.Core.Helpers; using Screenbox.Core.Messages; @@ -15,126 +16,139 @@ using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; -namespace Screenbox.Core.ViewModels +namespace Screenbox.Core.ViewModels; + +public sealed partial class CommonViewModel : ObservableRecipient, + IRecipient, + IRecipient>, + IRecipient> { - public sealed partial class CommonViewModel : ObservableRecipient, - IRecipient, - IRecipient>, - IRecipient> + public Dictionary NavigationStates { get; } + + public bool IsAdvancedModeEnabled => _settingsService.AdvancedMode; + + [ObservableProperty] private NavigationViewDisplayMode _navigationViewDisplayMode; + [ObservableProperty] private Thickness _scrollBarMargin; + [ObservableProperty] private Thickness _footerBottomPaddingMargin; + [ObservableProperty] private double _footerBottomPaddingHeight; + + private readonly INavigationService _navigationService; + private readonly IFilesService _filesService; + private readonly IResourceService _resourceService; + private readonly ISettingsService _settingsService; + private readonly IPlaylistService _playlistService; + private readonly PlaylistsContext _playlistsContext; + private readonly Dictionary _pageStates; + + public CommonViewModel(INavigationService navigationService, + IFilesService filesService, + IResourceService resourceService, + ISettingsService settingsService, + IPlaylistService playlistService, + PlaylistsContext playlistsContext) { - public Dictionary NavigationStates { get; } - - public bool IsAdvancedModeEnabled => _settingsService.AdvancedMode; - - [ObservableProperty] private NavigationViewDisplayMode _navigationViewDisplayMode; - [ObservableProperty] private Thickness _scrollBarMargin; - [ObservableProperty] private Thickness _footerBottomPaddingMargin; - [ObservableProperty] private double _footerBottomPaddingHeight; - - private readonly INavigationService _navigationService; - private readonly IFilesService _filesService; - private readonly IResourceService _resourceService; - private readonly ISettingsService _settingsService; - private readonly Dictionary _pageStates; + _navigationService = navigationService; + _filesService = filesService; + _resourceService = resourceService; + _settingsService = settingsService; + _playlistService = playlistService; + _playlistsContext = playlistsContext; + _navigationViewDisplayMode = Messenger.Send(); + NavigationStates = new Dictionary(); + _pageStates = new Dictionary(); + + // Activate the view model's messenger + IsActive = true; + } - public CommonViewModel(INavigationService navigationService, - IFilesService filesService, - IResourceService resourceService, - ISettingsService settingsService) + public void Receive(SettingsChangedMessage message) + { + if (message.SettingsName == nameof(SettingsPageViewModel.Theme) && + Window.Current.Content is Frame rootFrame) { - _navigationService = navigationService; - _filesService = filesService; - _resourceService = resourceService; - _settingsService = settingsService; - _navigationViewDisplayMode = Messenger.Send(); - NavigationStates = new Dictionary(); - _pageStates = new Dictionary(); - - // Activate the view model's messenger - IsActive = true; + rootFrame.RequestedTheme = _settingsService.Theme.ToElementTheme(); } + } - public void Receive(SettingsChangedMessage message) - { - if (message.SettingsName == nameof(SettingsPageViewModel.Theme) && - Window.Current.Content is Frame rootFrame) - { - rootFrame.RequestedTheme = _settingsService.Theme.ToElementTheme(); - } - } + public void Receive(PropertyChangedMessage message) + { + this.NavigationViewDisplayMode = message.NewValue; + } - public void Receive(PropertyChangedMessage message) - { - this.NavigationViewDisplayMode = message.NewValue; - } + public void Receive(PropertyChangedMessage message) + { + ScrollBarMargin = message.NewValue == PlayerVisibilityState.Hidden + ? new Thickness(0) + : (Thickness)Application.Current.Resources["ContentPageScrollBarMargin"]; - public void Receive(PropertyChangedMessage message) - { - ScrollBarMargin = message.NewValue == PlayerVisibilityState.Hidden - ? new Thickness(0) - : (Thickness)Application.Current.Resources["ContentPageScrollBarMargin"]; + FooterBottomPaddingMargin = message.NewValue == PlayerVisibilityState.Hidden + ? new Thickness(0) + : (Thickness)Application.Current.Resources["ContentPageBottomMargin"]; - FooterBottomPaddingMargin = message.NewValue == PlayerVisibilityState.Hidden - ? new Thickness(0) - : (Thickness)Application.Current.Resources["ContentPageBottomMargin"]; + FooterBottomPaddingHeight = message.NewValue == PlayerVisibilityState.Hidden + ? 0 + : (double)Application.Current.Resources["ContentPageBottomPaddingHeight"]; + } - FooterBottomPaddingHeight = message.NewValue == PlayerVisibilityState.Hidden - ? 0 - : (double)Application.Current.Resources["ContentPageBottomPaddingHeight"]; - } + public void SavePageState(object state, string pageTypeName, int backStackDepth) + { + _pageStates[pageTypeName + backStackDepth] = state; + } - public void SavePageState(object state, string pageTypeName, int backStackDepth) - { - _pageStates[pageTypeName + backStackDepth] = state; - } + public bool TryGetPageState(string pageTypeName, int backStackDepth, out object state) + { + return _pageStates.TryGetValue(pageTypeName + backStackDepth, out state); + } - public bool TryGetPageState(string pageTypeName, int backStackDepth, out object state) - { - return _pageStates.TryGetValue(pageTypeName + backStackDepth, out state); - } + [RelayCommand] + private void PlayNext(MediaViewModel media) + { + Messenger.SendPlayNext(media); + } - [RelayCommand] - private void PlayNext(MediaViewModel media) - { - Messenger.SendPlayNext(media); - } + [RelayCommand] + private void AddToQueue(MediaViewModel media) + { + Messenger.SendAddToQueue(media); + } - [RelayCommand] - private void AddToQueue(MediaViewModel media) - { - Messenger.SendAddToQueue(media); - } + [RelayCommand] + private void OpenAlbum(AlbumViewModel? album) + { + if (album == null) return; + _navigationService.Navigate(typeof(AlbumDetailsPageViewModel), + new NavigationMetadata(typeof(MusicPageViewModel), album)); + } - [RelayCommand] - private void OpenAlbum(AlbumViewModel? album) - { - if (album == null) return; - _navigationService.Navigate(typeof(AlbumDetailsPageViewModel), - new NavigationMetadata(typeof(MusicPageViewModel), album)); - } + [RelayCommand] + private void OpenArtist(ArtistViewModel? artist) + { + if (artist == null) return; + _navigationService.Navigate(typeof(ArtistDetailsPageViewModel), + new NavigationMetadata(typeof(MusicPageViewModel), artist)); + } - [RelayCommand] - private void OpenArtist(ArtistViewModel? artist) + [RelayCommand] + private void OpenPlaylist(PlaylistViewModel? playlist) + { + if (playlist == null) return; + _navigationService.Navigate(typeof(PlaylistDetailsPageViewModel), + new NavigationMetadata(typeof(PlaylistsPageViewModel), playlist)); + } + + [RelayCommand] + private async Task OpenFilesAsync() + { + try { - if (artist == null) return; - _navigationService.Navigate(typeof(ArtistDetailsPageViewModel), - new NavigationMetadata(typeof(MusicPageViewModel), artist)); + IReadOnlyList? files = await _filesService.PickMultipleFilesAsync(); + if (files == null || files.Count == 0) return; + Messenger.Send(new PlayMediaMessage(files)); } - - [RelayCommand] - private async Task OpenFilesAsync() + catch (Exception e) { - try - { - IReadOnlyList? files = await _filesService.PickMultipleFilesAsync(); - if (files == null || files.Count == 0) return; - Messenger.Send(new PlayMediaMessage(files)); - } - catch (Exception e) - { - Messenger.Send(new ErrorMessage( - _resourceService.GetString(ResourceName.FailedToOpenFilesNotificationTitle), e.Message)); - } + Messenger.Send(new ErrorMessage( + _resourceService.GetString(ResourceName.FailedToOpenFilesNotificationTitle), e.Message)); } } } diff --git a/Screenbox.Core/ViewModels/MainPageViewModel.cs b/Screenbox.Core/ViewModels/MainPageViewModel.cs index fdb1e5e82..550230753 100644 --- a/Screenbox.Core/ViewModels/MainPageViewModel.cs +++ b/Screenbox.Core/ViewModels/MainPageViewModel.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging.Messages; using Screenbox.Core.Contexts; @@ -47,17 +48,22 @@ public sealed partial class MainPageViewModel : ObservableRecipient, private readonly LibraryContext _libraryContext; private readonly ILibraryService _libraryService; private readonly LibraryController _libraryController; + private readonly PlaylistsContext _playlistsContext; + private readonly IPlaylistService _playlistService; public ObservableCollection SearchSuggestions { get; } = new(); public MainPageViewModel(ISearchService searchService, INavigationService navigationService, - LibraryContext libraryContext, ILibraryService libraryService, LibraryController libraryController) + LibraryContext libraryContext, ILibraryService libraryService, LibraryController libraryController, + PlaylistsContext playlistsContext, IPlaylistService playlistService) { _searchService = searchService; _navigationService = navigationService; _libraryContext = libraryContext; _libraryService = libraryService; _libraryController = libraryController; + _playlistsContext = playlistsContext; + _playlistService = playlistService; _searchQuery = string.Empty; _criticalErrorMessage = string.Empty; IsActive = true; @@ -244,7 +250,7 @@ public async Task FetchLibraries() // pass } - List tasks = new() { FetchMusicLibraryAsync(), FetchVideosLibraryAsync() }; + List tasks = new() { FetchMusicLibraryAsync(), FetchVideosLibraryAsync(), FetchPlaylistsAsync() }; await Task.WhenAll(tasks); } @@ -281,4 +287,27 @@ private async Task FetchVideosLibraryAsync() LogService.Log(e); } } + + /// + /// Fetches playlists from storage and populates the PlaylistsContext. + /// + private async Task FetchPlaylistsAsync() + { + try + { + var loaded = await _playlistService.ListPlaylistsAsync(); + _playlistsContext.Playlists.Clear(); + foreach (var p in loaded) + { + var playlist = Ioc.Default.GetRequiredService(); + playlist.Load(p); + _playlistsContext.Playlists.Add(playlist); + } + } + catch (Exception e) + { + Messenger.Send(new ErrorMessage(null, e.Message)); + LogService.Log(e); + } + } } diff --git a/Screenbox.Core/ViewModels/MediaListViewModel.cs b/Screenbox.Core/ViewModels/MediaListViewModel.cs index 365e12c0e..d069ef6d0 100644 --- a/Screenbox.Core/ViewModels/MediaListViewModel.cs +++ b/Screenbox.Core/ViewModels/MediaListViewModel.cs @@ -509,7 +509,7 @@ private void LoadFromPlaylist(Playlist playlist) { Items.SyncItems(playlist.Items); } - else + else if (!Items.SequenceEqual(playlist.Items)) { Items.Clear(); foreach (var item in playlist.Items) diff --git a/Screenbox.Core/ViewModels/PlaylistDetailsPageViewModel.cs b/Screenbox.Core/ViewModels/PlaylistDetailsPageViewModel.cs new file mode 100644 index 000000000..06f0520e8 --- /dev/null +++ b/Screenbox.Core/ViewModels/PlaylistDetailsPageViewModel.cs @@ -0,0 +1,92 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Screenbox.Core.Factories; +using Screenbox.Core.Helpers; +using Screenbox.Core.Messages; +using Screenbox.Core.Models; +using Screenbox.Core.Services; +using Windows.Storage; + +namespace Screenbox.Core.ViewModels; + +public sealed partial class PlaylistDetailsPageViewModel : ObservableRecipient +{ + [ObservableProperty] + private PlaylistViewModel? _source; + + private readonly IFilesService _filesService; + private readonly IPlaylistService _playlistService; + private readonly MediaViewModelFactory _mediaFactory; + + public PlaylistDetailsPageViewModel(IFilesService filesService, IPlaylistService playlistService, MediaViewModelFactory mediaFactory) + { + _filesService = filesService; + _playlistService = playlistService; + _mediaFactory = mediaFactory; + } + + public void OnNavigatedTo(object? parameter) + { + Source = parameter switch + { + NavigationMetadata { Parameter: PlaylistViewModel source } => source, + PlaylistViewModel source => source, + _ => throw new ArgumentException("Navigation parameter is not a playlist") + }; + } + + [RelayCommand] + private void Play(MediaViewModel item) + { + if (Source == null) return; + Messenger.SendQueueAndPlay(item, Source.Items); + } + + [RelayCommand] + private void ShuffleAndPlay() + { + if (Source == null || Source.Items.Count == 0) return; + Random rnd = new(); + List shuffledList = Source.Items.OrderBy(_ => rnd.Next()).ToList(); + var playlist = new Playlist(0, shuffledList); + Messenger.Send(new QueuePlaylistMessage(playlist, true)); + } + + [RelayCommand] + private async Task Remove(MediaViewModel item) + { + if (Source == null) return; + Source.Items.Remove(item); + await Source.SaveAsync(); + } + + [RelayCommand] + private async Task AddFilesAsync() + { + if (Source == null) return; + + IReadOnlyList? files = await _filesService.PickMultipleFilesAsync(); + if (files == null || files.Count == 0) return; + + var mediaList = files.Where(f => f.IsSupported()).Select(_mediaFactory.GetSingleton).ToList(); + if (mediaList.Count == 0) return; + + await Task.WhenAll(mediaList.Select(m => m.LoadDetailsAsync(_filesService))); + await Source.AddItemsAsync(mediaList); + } + + public async Task DeletePlaylistAsync() + { + if (Source == null) return false; + + await _playlistService.DeletePlaylistAsync(Source.Id); + return true; + } +} diff --git a/Screenbox.Core/ViewModels/PlaylistViewModel.cs b/Screenbox.Core/ViewModels/PlaylistViewModel.cs new file mode 100644 index 000000000..abd44af9f --- /dev/null +++ b/Screenbox.Core/ViewModels/PlaylistViewModel.cs @@ -0,0 +1,122 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Screenbox.Core.Enums; +using Screenbox.Core.Factories; +using Screenbox.Core.Models; +using Screenbox.Core.Services; + +namespace Screenbox.Core.ViewModels; + +public partial class PlaylistViewModel : ObservableObject +{ + public ObservableCollection Items { get; } = new(); + + public double ItemsCount => Items.Count; // For binding + + [ObservableProperty] private string _name = string.Empty; + [ObservableProperty] private bool _isPlaying; + [ObservableProperty] private object? _thumbnail; + [ObservableProperty] private DateTimeOffset _lastUpdated = DateTimeOffset.Now; + + public string Id => _id.ToString(); + + private Guid _id = Guid.NewGuid(); + + private readonly IPlaylistService _playlistService; + private readonly MediaViewModelFactory _mediaFactory; + + public PlaylistViewModel(IPlaylistService playlistService, MediaViewModelFactory mediaFactory) + { + _playlistService = playlistService; + _mediaFactory = mediaFactory; + + Items.CollectionChanged += Items_CollectionChanged; + } + + private void Items_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + OnPropertyChanged(nameof(ItemsCount)); + } + + public Playlist GetPlaylist() + { + return new Playlist(Items); + } + + public void Load(PersistentPlaylist persistentPlaylist) + { + if (!Guid.TryParse(persistentPlaylist.Id, out _id)) return; + Name = persistentPlaylist.DisplayName; + LastUpdated = persistentPlaylist.LastUpdated; + Items.Clear(); + foreach (var item in persistentPlaylist.Items) + { + var vm = ToMediaViewModel(item); + Items.Add(Items.Contains(vm) ? new MediaViewModel(vm) : vm); + } + } + + public async Task SaveAsync() + { + LastUpdated = DateTimeOffset.Now; + var persistentPlaylist = ToPersistentPlaylist(); + await _playlistService.SavePlaylistAsync(persistentPlaylist); + } + + public async Task RenameAsync(string newDisplayName) + { + Name = newDisplayName; + await SaveAsync(); + } + + [RelayCommand] + public async Task AddItemsAsync(IReadOnlyList items) + { + if (items.Count == 0) return; + foreach (var item in items) + { + Items.Add(Items.Contains(item) ? new MediaViewModel(item) : item); + } + + await SaveAsync(); + } + + private PersistentPlaylist ToPersistentPlaylist() + { + return new PersistentPlaylist + { + Id = _id.ToString(), + DisplayName = Name, + LastUpdated = LastUpdated, + Items = Items.Select(m => new PersistentMediaRecord( + m.Name, + m.Location, + m.MediaType == MediaPlaybackType.Music ? m.MediaInfo.MusicProperties : m.MediaInfo.VideoProperties, + m.DateAdded + )).ToList() + }; + } + + private MediaViewModel ToMediaViewModel(PersistentMediaRecord record) + { + MediaViewModel media = _mediaFactory.GetSingleton(new Uri(record.Path)); + if (!string.IsNullOrEmpty(record.Title)) media.Name = record.Title; + media.MediaInfo = record.Properties != null + ? new MediaInfo(record.Properties) + : new MediaInfo(record.MediaType, record.Title, record.Year, record.Duration); + + if (record.DateAdded != default) + { + DateTimeOffset utcTime = DateTime.SpecifyKind(record.DateAdded, DateTimeKind.Utc); + media.DateAdded = utcTime.ToLocalTime(); + } + return media; + } +} diff --git a/Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs b/Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs new file mode 100644 index 000000000..a7c6981c7 --- /dev/null +++ b/Screenbox.Core/ViewModels/PlaylistsPageViewModel.cs @@ -0,0 +1,48 @@ +#nullable enable + +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.DependencyInjection; +using Screenbox.Core.Contexts; +using Screenbox.Core.Services; + +namespace Screenbox.Core.ViewModels; + +public partial class PlaylistsPageViewModel : ObservableObject +{ + private readonly IPlaylistService _playlistService; + private readonly PlaylistsContext _playlistsContext; + + public ObservableCollection Playlists => _playlistsContext.Playlists; + + [ObservableProperty] private PlaylistViewModel? _selectedPlaylist; + + public PlaylistsPageViewModel(IPlaylistService playlistService, PlaylistsContext playlistsContext) + { + _playlistService = playlistService; + _playlistsContext = playlistsContext; + } + + public async Task CreatePlaylistAsync(string displayName) + { + // Create view model and add to collection + var playlist = Ioc.Default.GetRequiredService(); + playlist.Name = displayName; + await playlist.SaveAsync(); + + // Assume sort by last updated + Playlists.Insert(0, playlist); + } + + public async Task RenamePlaylistAsync(PlaylistViewModel playlist, string newDisplayName) + { + await playlist.RenameAsync(newDisplayName); + } + + public async Task DeletePlaylistAsync(PlaylistViewModel playlist) + { + await _playlistService.DeletePlaylistAsync(playlist.Id); + Playlists.Remove(playlist); + } +} diff --git a/Screenbox/App.xaml b/Screenbox/App.xaml index eb6d89197..3f8ffe480 100644 --- a/Screenbox/App.xaml +++ b/Screenbox/App.xaml @@ -93,6 +93,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..9df63fcee 100644 --- a/Screenbox/App.xaml.cs +++ b/Screenbox/App.xaml.cs @@ -30,277 +30,278 @@ using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Navigation; -namespace Screenbox +namespace Screenbox; + +/// +/// Provides application-specific behavior to supplement the default Application class. +/// +sealed partial class App : Application { /// - /// Provides application-specific behavior to supplement the default Application class. + /// Initializes the singleton application object. This is the first line of authored code + /// executed, and as such is the logical equivalent of main() or WinMain(). /// - sealed partial class App : Application + public App() { - /// - /// Initializes the singleton application object. This is the first line of authored code - /// executed, and as such is the logical equivalent of main() or WinMain(). - /// - public App() + ConfigureAppCenter(); + ConfigureSentry(); + InitializeComponent(); + + if (DeviceInfoHelper.IsXbox) { - ConfigureAppCenter(); - ConfigureSentry(); - InitializeComponent(); + // Disable pointer mode on Xbox + // https://learn.microsoft.com/en-us/windows/uwp/xbox-apps/how-to-disable-mouse-mode#xaml + RequiresPointerMode = ApplicationRequiresPointerMode.WhenRequested; - if (DeviceInfoHelper.IsXbox) - { - // Disable pointer mode on Xbox - // https://learn.microsoft.com/en-us/windows/uwp/xbox-apps/how-to-disable-mouse-mode#xaml - RequiresPointerMode = ApplicationRequiresPointerMode.WhenRequested; + // Use Reveal focus for 10-foot experience + // https://learn.microsoft.com/en-us/windows/apps/design/input/gamepad-and-remote-interactions#reveal-focus + FocusVisualKind = FocusVisualKind.Reveal; + } - // Use Reveal focus for 10-foot experience - // https://learn.microsoft.com/en-us/windows/apps/design/input/gamepad-and-remote-interactions#reveal-focus - FocusVisualKind = FocusVisualKind.Reveal; - } + // Disable automatic High Contrast adjustments + // https://learn.microsoft.com/en-us/windows/apps/design/accessibility/high-contrast-themes#setting-highcontrastadjustment-to-none + HighContrastAdjustment = ApplicationHighContrastAdjustment.None; - // Disable automatic High Contrast adjustments - // https://learn.microsoft.com/en-us/windows/apps/design/accessibility/high-contrast-themes#setting-highcontrastadjustment-to-none - HighContrastAdjustment = ApplicationHighContrastAdjustment.None; + Suspending += OnSuspending; - Suspending += OnSuspending; + IServiceProvider services = ConfigureServices(); + CommunityToolkit.Mvvm.DependencyInjection.Ioc.Default.ConfigureServices(services); + } - IServiceProvider services = ConfigureServices(); - CommunityToolkit.Mvvm.DependencyInjection.Ioc.Default.ConfigureServices(services); + [SecurityCritical] + [HandleProcessCorruptedStateExceptions] + private void CoreApplication_UnhandledErrorDetected(object sender, UnhandledErrorDetectedEventArgs e) + { + try + { + e.UnhandledError.Propagate(); } - - [SecurityCritical] - [HandleProcessCorruptedStateExceptions] - private void CoreApplication_UnhandledErrorDetected(object sender, UnhandledErrorDetectedEventArgs e) + catch (Exception ex) { - try + if (ex is VLCException { Message: "Could not create Direct3D11 device : No compatible adapter found." }) { - e.UnhandledError.Propagate(); + WeakReferenceMessenger.Default.Send(new CriticalErrorMessage(Strings.Resources.CriticalErrorDirect3D11NotAvailable)); + LogService.Log(ex); } - catch (Exception ex) + else { - if (ex is VLCException { Message: "Could not create Direct3D11 device : No compatible adapter found." }) - { - WeakReferenceMessenger.Default.Send(new CriticalErrorMessage(Strings.Resources.CriticalErrorDirect3D11NotAvailable)); - LogService.Log(ex); - } - else - { - // Tell Sentry this was an unhandled exception - ex.Data[Mechanism.HandledKey] = false; - ex.Data[Mechanism.MechanismKey] = "CoreApplication.UnhandledErrorDetected"; - - // Capture the exception - SentrySdk.CaptureException(ex); - - // Flush the event immediately - SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)).GetAwaiter().GetResult(); - throw; - } + // Tell Sentry this was an unhandled exception + ex.Data[Mechanism.HandledKey] = false; + ex.Data[Mechanism.MechanismKey] = "CoreApplication.UnhandledErrorDetected"; + + // Capture the exception + SentrySdk.CaptureException(ex); + + // Flush the event immediately + SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)).GetAwaiter().GetResult(); + throw; } } + } - private static IServiceProvider ConfigureServices() - { - ServiceCollection services = new(); - ServiceHelpers.PopulateCoreServices(services); - - // View models - services.AddTransient(provider => - new LivelyWallpaperSelectorViewModel( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - Strings.Resources.Default, "ms-appx:///Assets/DefaultAudioVisual.png")); - - // Factories - services.AddSingleton>(_ => () => new VLCLoginDialog()); - - // Services - services.AddSingleton(); - services.AddSingleton(_ => new NavigationService( - new KeyValuePair(typeof(HomePageViewModel), typeof(HomePage)), - new KeyValuePair(typeof(VideosPageViewModel), typeof(VideosPage)), - new KeyValuePair(typeof(AllVideosPageViewModel), typeof(AllVideosPage)), - new KeyValuePair(typeof(MusicPageViewModel), typeof(MusicPage)), - new KeyValuePair(typeof(SongsPageViewModel), typeof(SongsPage)), - new KeyValuePair(typeof(ArtistsPageViewModel), typeof(ArtistsPage)), - new KeyValuePair(typeof(AlbumsPageViewModel), typeof(AlbumsPage)), - new KeyValuePair(typeof(NetworkPageViewModel), typeof(NetworkPage)), - new KeyValuePair(typeof(PlayQueuePageViewModel), typeof(PlayQueuePage)), - new KeyValuePair(typeof(SettingsPageViewModel), typeof(SettingsPage)), - new KeyValuePair(typeof(AlbumDetailsPageViewModel), typeof(AlbumDetailsPage)), - new KeyValuePair(typeof(ArtistDetailsPageViewModel), typeof(ArtistDetailsPage)), - new KeyValuePair(typeof(SearchResultPageViewModel), typeof(SearchResultPage)), - new KeyValuePair(typeof(ArtistSearchResultPageViewModel), typeof(ArtistSearchResultPage)), - new KeyValuePair(typeof(AlbumSearchResultPageViewModel), typeof(AlbumSearchResultPage)), - new KeyValuePair(typeof(SongSearchResultPageViewModel), typeof(SongSearchResultPage)), - new KeyValuePair(typeof(VideoSearchResultPageViewModel), typeof(VideoSearchResultPage)), - new KeyValuePair(typeof(FolderViewPageViewModel), typeof(FolderViewPage)), - new KeyValuePair(typeof(FolderListViewPageViewModel), typeof(FolderListViewPage)) - )); - - return services.BuildServiceProvider(); - } + private static IServiceProvider ConfigureServices() + { + ServiceCollection services = new(); + ServiceHelpers.PopulateCoreServices(services); + + // View models + services.AddTransient(provider => + new LivelyWallpaperSelectorViewModel( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + Strings.Resources.Default, "ms-appx:///Assets/DefaultAudioVisual.png")); + + // Factories + services.AddSingleton>(_ => () => new VLCLoginDialog()); + + // Services + services.AddSingleton(); + services.AddSingleton(_ => new NavigationService( + new KeyValuePair(typeof(HomePageViewModel), typeof(HomePage)), + new KeyValuePair(typeof(PlaylistsPageViewModel), typeof(PlaylistsPage)), + new KeyValuePair(typeof(PlaylistDetailsPageViewModel), typeof(PlaylistDetailsPage)), + new KeyValuePair(typeof(VideosPageViewModel), typeof(VideosPage)), + new KeyValuePair(typeof(AllVideosPageViewModel), typeof(AllVideosPage)), + new KeyValuePair(typeof(MusicPageViewModel), typeof(MusicPage)), + new KeyValuePair(typeof(SongsPageViewModel), typeof(SongsPage)), + new KeyValuePair(typeof(ArtistsPageViewModel), typeof(ArtistsPage)), + new KeyValuePair(typeof(AlbumsPageViewModel), typeof(AlbumsPage)), + new KeyValuePair(typeof(NetworkPageViewModel), typeof(NetworkPage)), + new KeyValuePair(typeof(PlayQueuePageViewModel), typeof(PlayQueuePage)), + new KeyValuePair(typeof(SettingsPageViewModel), typeof(SettingsPage)), + new KeyValuePair(typeof(AlbumDetailsPageViewModel), typeof(AlbumDetailsPage)), + new KeyValuePair(typeof(ArtistDetailsPageViewModel), typeof(ArtistDetailsPage)), + new KeyValuePair(typeof(SearchResultPageViewModel), typeof(SearchResultPage)), + new KeyValuePair(typeof(ArtistSearchResultPageViewModel), typeof(ArtistSearchResultPage)), + new KeyValuePair(typeof(AlbumSearchResultPageViewModel), typeof(AlbumSearchResultPage)), + new KeyValuePair(typeof(SongSearchResultPageViewModel), typeof(SongSearchResultPage)), + new KeyValuePair(typeof(VideoSearchResultPageViewModel), typeof(VideoSearchResultPage)), + new KeyValuePair(typeof(FolderViewPageViewModel), typeof(FolderViewPage)), + new KeyValuePair(typeof(FolderListViewPageViewModel), typeof(FolderListViewPage)) + )); + + return services.BuildServiceProvider(); + } - private static void ConfigureAppCenter() - { - AppCenter.Start(Secrets.AppCenterApiKey, typeof(Analytics), typeof(Crashes)); - } + private static void ConfigureAppCenter() + { + AppCenter.Start(Secrets.AppCenterApiKey, typeof(Analytics), typeof(Crashes)); + } + + private void ConfigureSentry() + { + CoreApplication.UnhandledErrorDetected += CoreApplication_UnhandledErrorDetected; - private void ConfigureSentry() + SentrySdk.Init(options => { - CoreApplication.UnhandledErrorDetected += CoreApplication_UnhandledErrorDetected; + options.Dsn = Secrets.SentryDsn; + options.SampleRate = 1.0f; + // options.StackTraceMode = StackTraceMode.Enhanced; // Not supported in UWP + options.IsGlobalModeEnabled = true; + options.AutoSessionTracking = true; + options.Release = $"screenbox@{Package.Current.Id.Version.ToFormattedString(3)}"; + }); + + SentrySdk.ConfigureScope(scope => + { + scope.SetTag("device_family", DeviceInfoHelper.DeviceFamily); + }); + } - SentrySdk.Init(options => - { - options.Dsn = Secrets.SentryDsn; - options.SampleRate = 1.0f; - // options.StackTraceMode = StackTraceMode.Enhanced; // Not supported in UWP - options.IsGlobalModeEnabled = true; - options.AutoSessionTracking = true; - options.Release = $"screenbox@{Package.Current.Id.Version.ToFormattedString(3)}"; - }); - - SentrySdk.ConfigureScope(scope => - { - scope.SetTag("device_family", DeviceInfoHelper.DeviceFamily); - }); - } + private void SetMinWindowSize() + { + //var view = ApplicationView.GetForCurrentView(); + //view.SetPreferredMinSize(new Size(480, 270)); + } - private void SetMinWindowSize() + protected override void OnFileActivated(FileActivatedEventArgs args) + { + SentrySdk.AddBreadcrumb("File activated", category: "activation", type: "user", data: new Dictionary { - //var view = ApplicationView.GetForCurrentView(); - //view.SetPreferredMinSize(new Size(480, 270)); - } + { "PreviousExecutionState", args.PreviousExecutionState.ToString() } + }); - protected override void OnFileActivated(FileActivatedEventArgs args) + Frame rootFrame = InitRootFrame(); + if (rootFrame.Content is not MainPage) { - SentrySdk.AddBreadcrumb("File activated", category: "activation", type: "user", data: new Dictionary - { - { "PreviousExecutionState", args.PreviousExecutionState.ToString() } - }); - - Frame rootFrame = InitRootFrame(); - if (rootFrame.Content is not MainPage) - { - rootFrame.Navigate(typeof(MainPage), true); - } - - Window.Current.Activate(); - WeakReferenceMessenger.Default.Send(new PlayFilesMessage(args.Files, args.NeighboringFilesQuery)); + rootFrame.Navigate(typeof(MainPage), true); } - /// - /// Invoked when the application is launched normally by the end user. Other entry points - /// will be used such as when the application is launched to open a specific file. - /// - /// Details about the launch request and process. - protected override void OnLaunched(LaunchActivatedEventArgs e) + Window.Current.Activate(); + WeakReferenceMessenger.Default.Send(new PlayFilesMessage(args.Files, args.NeighboringFilesQuery)); + } + + /// + /// Invoked when the application is launched normally by the end user. Other entry points + /// will be used such as when the application is launched to open a specific file. + /// + /// Details about the launch request and process. + protected override void OnLaunched(LaunchActivatedEventArgs e) + { + SentrySdk.AddBreadcrumb("Launched", category: "lifecycle", data: new Dictionary { - SentrySdk.AddBreadcrumb("Launched", category: "lifecycle", data: new Dictionary - { - { "PrelaunchActivated", e.PrelaunchActivated.ToString() }, - { "PreviousExecutionState", e.PreviousExecutionState.ToString() } - }); + { "PrelaunchActivated", e.PrelaunchActivated.ToString() }, + { "PreviousExecutionState", e.PreviousExecutionState.ToString() } + }); - Frame rootFrame = InitRootFrame(); - LibVLCSharp.Shared.Core.Initialize(); + Frame rootFrame = InitRootFrame(); + LibVLCSharp.Shared.Core.Initialize(); - if (e.PrelaunchActivated) return; - CoreApplication.EnablePrelaunch(true); - if (rootFrame.Content == null) - { - SetMinWindowSize(); - rootFrame.Navigate(typeof(MainPage)); - } + if (e.PrelaunchActivated) return; + CoreApplication.EnablePrelaunch(true); + if (rootFrame.Content == null) + { + SetMinWindowSize(); + rootFrame.Navigate(typeof(MainPage)); + } - // Ensure the current window is active - Window.Current.Activate(); + // Ensure the current window is active + Window.Current.Activate(); #if DEBUG - if (System.Diagnostics.Debugger.IsAttached) - { - //DebugSettings.EnableFrameRateCounter = true; - //DebugSettings.EnableRedrawRegions = true; - //DebugSettings.FailFastOnErrors = true; - //DebugSettings.IsBindingTracingEnabled = true; - //DebugSettings.IsOverdrawHeatMapEnabled = true; - //DebugSettings.IsTextPerformanceVisualizationEnabled = true; - } -#endif - } - - /// - /// Invoked when Navigation to a certain page fails - /// - /// The Frame which failed navigation - /// Details about the navigation failure - void OnNavigationFailed(object sender, NavigationFailedEventArgs e) + if (System.Diagnostics.Debugger.IsAttached) { - throw new Exception("Failed to load Page " + e.SourcePageType.FullName); + //DebugSettings.EnableFrameRateCounter = true; + //DebugSettings.EnableRedrawRegions = true; + //DebugSettings.FailFastOnErrors = true; + //DebugSettings.IsBindingTracingEnabled = true; + //DebugSettings.IsOverdrawHeatMapEnabled = true; + //DebugSettings.IsTextPerformanceVisualizationEnabled = true; } +#endif + } - /// - /// Invoked when application execution is being suspended. Application state is saved - /// without knowing whether the application will be terminated or resumed with the contents - /// of memory still intact. - /// - /// The source of the suspend request. - /// Details about the suspend request. - private async void OnSuspending(object sender, SuspendingEventArgs e) - { - SuspendingDeferral deferral = e.SuspendingOperation.GetDeferral(); - SentrySdk.AddBreadcrumb("Suspending", category: "lifecycle"); - IReadOnlyCollection tasks = WeakReferenceMessenger.Default.Send().Responses; - await Task.WhenAll(tasks); - await SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)); - deferral.Complete(); - } + /// + /// Invoked when Navigation to a certain page fails + /// + /// The Frame which failed navigation + /// Details about the navigation failure + void OnNavigationFailed(object sender, NavigationFailedEventArgs e) + { + throw new Exception("Failed to load Page " + e.SourcePageType.FullName); + } - private Frame InitRootFrame() - { - // Do not repeat app initialization when the Window already has content, - // just ensure that the window is active - if (Window.Current.Content is not Frame rootFrame) - { - // Create a Frame to act as the navigation context and navigate to the first page - rootFrame = new Frame(); + /// + /// Invoked when application execution is being suspended. Application state is saved + /// without knowing whether the application will be terminated or resumed with the contents + /// of memory still intact. + /// + /// The source of the suspend request. + /// Details about the suspend request. + private async void OnSuspending(object sender, SuspendingEventArgs e) + { + SuspendingDeferral deferral = e.SuspendingOperation.GetDeferral(); + SentrySdk.AddBreadcrumb("Suspending", category: "lifecycle"); + IReadOnlyCollection tasks = WeakReferenceMessenger.Default.Send().Responses; + await Task.WhenAll(tasks); + await SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)); + deferral.Complete(); + } - rootFrame.NavigationFailed += OnNavigationFailed; + private Frame InitRootFrame() + { + // Do not repeat app initialization when the Window already has content, + // just ensure that the window is active + if (Window.Current.Content is not Frame rootFrame) + { + // Create a Frame to act as the navigation context and navigate to the first page + rootFrame = new Frame(); - // Place the frame in the current Window - Window.Current.Content = rootFrame; - SetMinWindowSize(); + rootFrame.NavigationFailed += OnNavigationFailed; - // Turn off overscan on Xbox - // https://learn.microsoft.com/en-us/windows/uwp/xbox-apps/turn-off-overscan - if (DeviceInfoHelper.IsXbox) - { - Windows.UI.ViewManagement.ApplicationView.GetForCurrentView() - .SetDesiredBoundsMode(Windows.UI.ViewManagement.ApplicationViewBoundsMode.UseCoreWindow); - } + // Place the frame in the current Window + Window.Current.Content = rootFrame; + SetMinWindowSize(); - // Check for RTL flow direction - if (GlobalizationHelper.IsRightToLeftLanguage) - { - rootFrame.FlowDirection = FlowDirection.RightToLeft; - } + // Turn off overscan on Xbox + // https://learn.microsoft.com/en-us/windows/uwp/xbox-apps/turn-off-overscan + if (DeviceInfoHelper.IsXbox) + { + Windows.UI.ViewManagement.ApplicationView.GetForCurrentView() + .SetDesiredBoundsMode(Windows.UI.ViewManagement.ApplicationViewBoundsMode.UseCoreWindow); + } - var settings = CommunityToolkit.Mvvm.DependencyInjection.Ioc.Default.GetRequiredService(); - rootFrame.RequestedTheme = settings.Theme.ToElementTheme(); + // Check for RTL flow direction + if (GlobalizationHelper.IsRightToLeftLanguage) + { + rootFrame.FlowDirection = FlowDirection.RightToLeft; + } - // Register a handler for when the theme mode changes - rootFrame.ActualThemeChanged += OnActualThemeChanged; + var settings = CommunityToolkit.Mvvm.DependencyInjection.Ioc.Default.GetRequiredService(); + rootFrame.RequestedTheme = settings.Theme.ToElementTheme(); - TitleBarHelper.SetCaptionButtonColors(rootFrame); - } + // Register a handler for when the theme mode changes + rootFrame.ActualThemeChanged += OnActualThemeChanged; - return rootFrame; + TitleBarHelper.SetCaptionButtonColors(rootFrame); } - private void OnActualThemeChanged(FrameworkElement sender, object args) - { - TitleBarHelper.SetCaptionButtonColors(sender); - } + return rootFrame; + } + + private void OnActualThemeChanged(FrameworkElement sender, object args) + { + TitleBarHelper.SetCaptionButtonColors(sender); } } diff --git a/Screenbox/Behaviors/AddToPlaylistFlyoutSubmenuBehavior.cs b/Screenbox/Behaviors/AddToPlaylistFlyoutSubmenuBehavior.cs new file mode 100644 index 000000000..4f5ee5643 --- /dev/null +++ b/Screenbox/Behaviors/AddToPlaylistFlyoutSubmenuBehavior.cs @@ -0,0 +1,163 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Xaml.Interactivity; +using Screenbox.Controls; +using Screenbox.Core.Contexts; +using Screenbox.Core.ViewModels; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; + +namespace Screenbox.Behaviors; + +/// +/// Populates a named within a with playlist actions. +/// +internal sealed class AddToPlaylistFlyoutSubmenuBehavior : Behavior +{ + public static readonly DependencyProperty TargetSubItemNameProperty = DependencyProperty.Register( + nameof(TargetSubItemName), + typeof(string), + typeof(AddToPlaylistFlyoutSubmenuBehavior), + new PropertyMetadata(string.Empty)); + + /// + /// Gets or sets the x:Name of the that should be populated. + /// + public string TargetSubItemName + { + get => (string)GetValue(TargetSubItemNameProperty); + set => SetValue(TargetSubItemNameProperty, value); + } + + public IAsyncRelayCommand CreatePlaylistCommand { get; } + + private readonly PlaylistsContext _playlistsContext; + + public AddToPlaylistFlyoutSubmenuBehavior() + { + _playlistsContext = Ioc.Default.GetRequiredService(); + CreatePlaylistCommand = new AsyncRelayCommand(CreatePlaylistAsync); + } + + protected override void OnAttached() + { + base.OnAttached(); + + // Populate each time the flyout opens, so it reflects the latest playlists. + AssociatedObject.Opening += AssociatedObjectOnOpening; + } + + protected override void OnDetaching() + { + base.OnDetaching(); + + AssociatedObject.Opening -= AssociatedObjectOnOpening; + } + + private void AssociatedObjectOnOpening(object sender, object e) + { + PopulateMenu(); + } + + private void PopulateMenu() + { + if (string.IsNullOrWhiteSpace(TargetSubItemName)) + { + return; + } + + if (!TryFindSubItem(AssociatedObject.Items, TargetSubItemName, out var targetSubItem)) + { + return; + } + + MediaViewModel? clicked = targetSubItem.DataContext switch + { + StorageItemViewModel svm => svm.Media, + MediaViewModel vm => vm, + _ => null, + }; + IReadOnlyList clickedItems = clicked is not null + ? [clicked] + : Array.Empty(); + + targetSubItem.Items.Clear(); + targetSubItem.Items.Add(new MenuFlyoutItem + { + Icon = new SymbolIcon(Symbol.Add), + Text = Strings.Resources.CreateNewPlaylist, + Command = CreatePlaylistCommand, + CommandParameter = clicked + }); + + targetSubItem.Items.Add(new MenuFlyoutSeparator()); + + if (_playlistsContext.Playlists.Count == 0) + { + targetSubItem.Items.Add(new MenuFlyoutItem + { + Text = Strings.Resources.NoPlaylists, + IsEnabled = false + }); + return; + } + + foreach (var playlist in _playlistsContext.Playlists.Where(p => p is not null)) + { + targetSubItem.Items.Add(new MenuFlyoutItem + { + Text = playlist.Name, + Command = playlist.AddItemsCommand, + CommandParameter = clickedItems + }); + } + } + + private async Task CreatePlaylistAsync(MediaViewModel? parameter) + { + var playlistName = await CreatePlaylistDialog.GetPlaylistNameAsync(); + if (string.IsNullOrWhiteSpace(playlistName)) + return; + + var playlist = Ioc.Default.GetRequiredService(); + playlist.Name = playlistName!; + if (parameter != null) + { + playlist.Items.Add(parameter); + } + + await playlist.SaveAsync(); + + // Assume sort by last updated + _playlistsContext.Playlists.Insert(0, playlist); + } + + private static bool TryFindSubItem(IList items, string name, out MenuFlyoutSubItem subItem) + { + for (int i = 0; i < items.Count; i++) + { + if (items[i] is MenuFlyoutSubItem candidate) + { + if (string.Equals(candidate.Name, name, StringComparison.Ordinal)) + { + subItem = candidate; + return true; + } + + if (candidate.Items is { Count: > 0 } && TryFindSubItem(candidate.Items, name, out subItem)) + { + return true; + } + } + } + + subItem = null!; + return false; + } +} diff --git a/Screenbox/Controls/CreatePlaylistDialog.xaml b/Screenbox/Controls/CreatePlaylistDialog.xaml new file mode 100644 index 000000000..3081e4357 --- /dev/null +++ b/Screenbox/Controls/CreatePlaylistDialog.xaml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/Screenbox/Controls/CreatePlaylistDialog.xaml.cs b/Screenbox/Controls/CreatePlaylistDialog.xaml.cs new file mode 100644 index 000000000..a8891dd36 --- /dev/null +++ b/Screenbox/Controls/CreatePlaylistDialog.xaml.cs @@ -0,0 +1,40 @@ +#nullable enable + +using System; +using System.Threading.Tasks; +using Screenbox.Helpers; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; + +// The Content Dialog item template is documented at https://go.microsoft.com/fwlink/?LinkId=234238 + +namespace Screenbox.Controls; + +public sealed partial class CreatePlaylistDialog : ContentDialog +{ + private const int MaxPlaylistNameLength = 100; + + public CreatePlaylistDialog() + { + this.DefaultStyleKey = typeof(ContentDialog); + this.InitializeComponent(); + FlowDirection = GlobalizationHelper.GetFlowDirection(); + RequestedTheme = ((FrameworkElement)Window.Current.Content).RequestedTheme; + } + + public static async Task GetPlaylistNameAsync() + { + CreatePlaylistDialog dialog = new(); + ContentDialogResult result = await dialog.ShowAsync(); + string playlistName = dialog.PlaylistNameTextBox.Text.Trim(); + return result == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(playlistName) + ? playlistName + : null; + } + + private void PlaylistNameTextBox_OnTextChanged(object sender, TextChangedEventArgs e) + { + string text = PlaylistNameTextBox.Text.Trim(); + IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(text) && text.Length <= MaxPlaylistNameLength; + } +} diff --git a/Screenbox/Controls/DeletePlaylistDialog.xaml b/Screenbox/Controls/DeletePlaylistDialog.xaml new file mode 100644 index 000000000..3755ecc2d --- /dev/null +++ b/Screenbox/Controls/DeletePlaylistDialog.xaml @@ -0,0 +1,17 @@ + + + + diff --git a/Screenbox/Controls/DeletePlaylistDialog.xaml.cs b/Screenbox/Controls/DeletePlaylistDialog.xaml.cs new file mode 100644 index 000000000..55b4b87c6 --- /dev/null +++ b/Screenbox/Controls/DeletePlaylistDialog.xaml.cs @@ -0,0 +1,21 @@ +using Screenbox.Helpers; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; + +// The Content Dialog item template is documented at https://go.microsoft.com/fwlink/?LinkId=234238 + +namespace Screenbox.Controls; + +public sealed partial class DeletePlaylistDialog : ContentDialog +{ + private string PlaylistName { get; } + + public DeletePlaylistDialog(string playlistName) + { + this.DefaultStyleKey = typeof(ContentDialog); + this.InitializeComponent(); + FlowDirection = GlobalizationHelper.GetFlowDirection(); + RequestedTheme = ((FrameworkElement)Window.Current.Content).RequestedTheme; + PlaylistName = playlistName; + } +} diff --git a/Screenbox/Controls/PlayQueueControl.xaml b/Screenbox/Controls/PlayQueueControl.xaml index b522b4d99..dfa6e6133 100644 --- a/Screenbox/Controls/PlayQueueControl.xaml +++ b/Screenbox/Controls/PlayQueueControl.xaml @@ -30,7 +30,10 @@ - + + + + + - + diff --git a/Screenbox/Controls/RenamePlaylistDialog.xaml b/Screenbox/Controls/RenamePlaylistDialog.xaml new file mode 100644 index 000000000..3284ea7ae --- /dev/null +++ b/Screenbox/Controls/RenamePlaylistDialog.xaml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/Screenbox/Controls/RenamePlaylistDialog.xaml.cs b/Screenbox/Controls/RenamePlaylistDialog.xaml.cs new file mode 100644 index 000000000..d8afe4e5d --- /dev/null +++ b/Screenbox/Controls/RenamePlaylistDialog.xaml.cs @@ -0,0 +1,39 @@ +#nullable enable + +using System; +using System.Threading.Tasks; +using Screenbox.Helpers; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; + +namespace Screenbox.Controls; + +public sealed partial class RenamePlaylistDialog : ContentDialog +{ + private const int MaxPlaylistNameLength = 100; + + public RenamePlaylistDialog(string currentName) + { + this.DefaultStyleKey = typeof(ContentDialog); + this.InitializeComponent(); + FlowDirection = GlobalizationHelper.GetFlowDirection(); + RequestedTheme = ((FrameworkElement)Window.Current.Content).RequestedTheme; + PlaylistNameTextBox.Text = currentName; + PlaylistNameTextBox.SelectAll(); + } + + public async Task GetPlaylistNameAsync() + { + ContentDialogResult result = await ShowAsync(); + string playlistName = PlaylistNameTextBox.Text.Trim(); + return result == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(playlistName) + ? playlistName + : null; + } + + private void PlaylistNameTextBox_OnTextChanged(object sender, TextChangedEventArgs e) + { + string text = PlaylistNameTextBox.Text.Trim(); + IsPrimaryButtonEnabled = !string.IsNullOrWhiteSpace(text) && text.Length <= MaxPlaylistNameLength; + } +} diff --git a/Screenbox/Pages/AlbumDetailsPage.xaml b/Screenbox/Pages/AlbumDetailsPage.xaml index 3a2673bc3..376fd4978 100644 --- a/Screenbox/Pages/AlbumDetailsPage.xaml +++ b/Screenbox/Pages/AlbumDetailsPage.xaml @@ -23,7 +23,11 @@ - + + + + + + - + diff --git a/Screenbox/Pages/AllVideosPage.xaml b/Screenbox/Pages/AllVideosPage.xaml index 0691c2400..78f097676 100644 --- a/Screenbox/Pages/AllVideosPage.xaml +++ b/Screenbox/Pages/AllVideosPage.xaml @@ -19,7 +19,11 @@ - + + + + + + - + diff --git a/Screenbox/Pages/ArtistDetailsPage.xaml b/Screenbox/Pages/ArtistDetailsPage.xaml index 005744069..9fddb3732 100644 --- a/Screenbox/Pages/ArtistDetailsPage.xaml +++ b/Screenbox/Pages/ArtistDetailsPage.xaml @@ -111,7 +111,11 @@ - + + + + + + - + diff --git a/Screenbox/Pages/FolderListViewPage.xaml b/Screenbox/Pages/FolderListViewPage.xaml index 7bf410ebb..a7d708497 100644 --- a/Screenbox/Pages/FolderListViewPage.xaml +++ b/Screenbox/Pages/FolderListViewPage.xaml @@ -16,7 +16,10 @@ mc:Ignorable="d"> - + + + + + - + diff --git a/Screenbox/Pages/FolderViewPage.xaml b/Screenbox/Pages/FolderViewPage.xaml index 2802f34c7..941d8f7d0 100644 --- a/Screenbox/Pages/FolderViewPage.xaml +++ b/Screenbox/Pages/FolderViewPage.xaml @@ -19,7 +19,10 @@ - + + + + + - + diff --git a/Screenbox/Pages/HomePage.xaml b/Screenbox/Pages/HomePage.xaml index 9212c7a42..59d9f1238 100644 --- a/Screenbox/Pages/HomePage.xaml +++ b/Screenbox/Pages/HomePage.xaml @@ -25,7 +25,11 @@ - + + + + + + - + diff --git a/Screenbox/Pages/MainPage.xaml b/Screenbox/Pages/MainPage.xaml index d69c2a79e..9e20ab708 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"> @@ -186,6 +187,9 @@ + + + diff --git a/Screenbox/Pages/MainPage.xaml.cs b/Screenbox/Pages/MainPage.xaml.cs index 2d32b2f6f..3c30b4299 100644 --- a/Screenbox/Pages/MainPage.xaml.cs +++ b/Screenbox/Pages/MainPage.xaml.cs @@ -62,6 +62,7 @@ public MainPage() { "music", typeof(MusicPage) }, { "queue", typeof(PlayQueuePage) }, { "network", typeof(NetworkPage) }, + { "playlists", typeof(PlaylistsPage) }, { "settings", typeof(SettingsPage) } }; diff --git a/Screenbox/Pages/PlaylistDetailsPage.xaml b/Screenbox/Pages/PlaylistDetailsPage.xaml new file mode 100644 index 000000000..e5a400e9e --- /dev/null +++ b/Screenbox/Pages/PlaylistDetailsPage.xaml @@ -0,0 +1,300 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 4,8,4,8 + 16,16,0,16 + 8,14,16,15 + 4,8,4,8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Minimal + + + + + + + + + + + + + + + + + + diff --git a/Screenbox/Pages/PlaylistDetailsPage.xaml.cs b/Screenbox/Pages/PlaylistDetailsPage.xaml.cs new file mode 100644 index 000000000..c5afeb606 --- /dev/null +++ b/Screenbox/Pages/PlaylistDetailsPage.xaml.cs @@ -0,0 +1,231 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Numerics; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.WinUI; +using CommunityToolkit.WinUI.Animations.Expressions; +using Screenbox.Controls; +using Screenbox.Core; +using Screenbox.Core.ViewModels; +using Windows.UI.Composition; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; +using Windows.UI.Xaml.Hosting; +using Windows.UI.Xaml.Navigation; +using EF = CommunityToolkit.WinUI.Animations.Expressions.ExpressionFunctions; +using NavigationViewDisplayMode = Windows.UI.Xaml.Controls.NavigationViewDisplayMode; + +namespace Screenbox.Pages; + +/// +/// A page that displays playlist details with media items, allowing users to play, shuffle, add, and remove media. +/// +public sealed partial class PlaylistDetailsPage : Page +{ + internal PlaylistDetailsPageViewModel ViewModel => (PlaylistDetailsPageViewModel)DataContext; + + internal CommonViewModel Common { get; } + + private int ClampSize => Common.NavigationViewDisplayMode == NavigationViewDisplayMode.Minimal ? 64 : 96; + + private float BackgroundScaleFactor => Common.NavigationViewDisplayMode == NavigationViewDisplayMode.Minimal ? 0.75f : 0.625f; + + private float CoverScaleFactor => Common.NavigationViewDisplayMode == NavigationViewDisplayMode.Minimal ? 0.6f : 0.5f; + + private int ButtonPanelOffset => Common.NavigationViewDisplayMode == NavigationViewDisplayMode.Minimal ? 56 : 64; + + private float BackgroundVisualHeight => (float)(Header.ActualHeight * 2.5); + + private CompositionPropertySet? _props; + private CompositionPropertySet? _scrollerPropertySet; + private Compositor? _compositor; + private SpriteVisual? _backgroundVisual; + private ScrollViewer? _scrollViewer; + + public PlaylistDetailsPage() + { + this.InitializeComponent(); + DataContext = Ioc.Default.GetRequiredService(); + Common = Ioc.Default.GetRequiredService(); + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + ViewModel.OnNavigatedTo(e.Parameter); + } + + private void PlaylistDetailsPage_OnLoaded(object sender, RoutedEventArgs e) + { + // Retrieve the ScrollViewer that the ListView is using internally + ScrollViewer scrollViewer = _scrollViewer = ItemList.FindDescendant() ?? + throw new Exception("Cannot find ScrollViewer in ListView"); + + // Get the PropertySet that contains the scroll values from the ScrollViewer + _scrollerPropertySet = ElementCompositionPreview.GetScrollViewerManipulationPropertySet(scrollViewer); + _compositor = _scrollerPropertySet.Compositor; + + // Create a PropertySet that has values to be referenced in the ExpressionAnimations below + _props = _compositor.CreatePropertySet(); + _props.InsertScalar("progress", 0); + _props.InsertScalar("clampSize", ClampSize); + _props.InsertScalar("backgroundScaleFactor", BackgroundScaleFactor); + _props.InsertScalar("coverScaleFactor", CoverScaleFactor); + _props.InsertScalar("buttonPanelOffset", ButtonPanelOffset); + _props.InsertScalar("headerPadding", 12); + + // Get references to our property sets for use with ExpressionNodes + ManipulationPropertySetReferenceNode scrollingProperties = _scrollerPropertySet.GetSpecializedReference(); + + CreateHeaderAnimation(_props, scrollingProperties.Translation.Y); + } + + private void CreateHeaderAnimation(CompositionPropertySet propSet, ScalarNode scrollVerticalOffset) + { + PropertySetReferenceNode props = propSet.GetReference(); + ScalarNode progressNode = props.GetScalarProperty("progress"); + ScalarNode clampSizeNode = props.GetScalarProperty("clampSize"); + ScalarNode backgroundScaleFactorNode = props.GetScalarProperty("backgroundScaleFactor"); + ScalarNode coverScaleFactorNode = props.GetScalarProperty("coverScaleFactor"); + ScalarNode buttonPanelOffsetNode = props.GetScalarProperty("buttonPanelOffset"); + ScalarNode headerPaddingNode = props.GetScalarProperty("headerPadding"); + + // Create and start an ExpressionAnimation to track scroll progress over the desired distance + ExpressionNode progressAnimation = EF.Clamp(-scrollVerticalOffset / clampSizeNode, 0, 1); + propSet.StartAnimation("progress", progressAnimation); + + // Get the backing visual for the background in the header so that its properties can be animated + Visual backgroundVisual = ElementCompositionPreview.GetElementVisual(BackgroundAcrylic); + + // Create and start an ExpressionAnimation to scale and opacity fade in the backgound behind the header + ExpressionNode backgroundScaleAnimation = EF.Lerp(1, backgroundScaleFactorNode, progressNode); + ExpressionNode backgroundOpacityAnimation = progressNode; + backgroundVisual.StartAnimation("Scale.Y", backgroundScaleAnimation); + backgroundVisual.StartAnimation("Opacity", backgroundOpacityAnimation); + + // Get the backing visuals for the content container so that its properties can be animated + Visual contentVisual = ElementCompositionPreview.GetElementVisual(ContentContainer); + ElementCompositionPreview.SetIsTranslationEnabled(ContentContainer, true); + + // Create and start an ExpressionAnimation to move the content container with scroll position + ExpressionNode contentTranslationAnimation = progressNode * headerPaddingNode; + contentVisual.StartAnimation("Translation.Y", contentTranslationAnimation); + + // Get the backing visual for the cover art visual so that its properties can be animated + Visual coverArtVisual = ElementCompositionPreview.GetElementVisual(CoverArt); + ElementCompositionPreview.SetIsTranslationEnabled(CoverArt, true); + + // Create and start an ExpressionAnimation to scale and move the cover art with scroll position + ExpressionNode coverArtScaleAnimation = EF.Lerp(1, coverScaleFactorNode, progressNode); + ExpressionNode coverArtTranslationAnimation = progressNode * headerPaddingNode; + coverArtVisual.StartAnimation("Scale.X", coverArtScaleAnimation); + coverArtVisual.StartAnimation("Scale.Y", coverArtScaleAnimation); + coverArtVisual.StartAnimation("Translation.X", coverArtTranslationAnimation); + + // Get the backing visual for the text panel so that its properties can be animated + Visual textVisual = ElementCompositionPreview.GetElementVisual(TextPanel); + ElementCompositionPreview.SetIsTranslationEnabled(TextPanel, true); + + // Create and start an ExpressionAnimation to move the text panel with scroll position + ExpressionNode textTranslationAnimation = progressNode * (-clampSizeNode + headerPaddingNode); + textVisual.StartAnimation("Translation.X", textTranslationAnimation); + + // Get backing visuals for the additional text blocks so that their properties can be animated + Visual subtitleVisual = ElementCompositionPreview.GetElementVisual(SubtitleText); + Visual captionVisual = ElementCompositionPreview.GetElementVisual(CaptionText); + + // Create an ExpressionAnimation that start opacity fade out animation with threshold for the additional text blocks + ScalarNode fadeThreshold = ExpressionValues.Constant.CreateConstantScalar("fadeThreshold", 0.6f); + ExpressionNode textFadeAnimation = 1 - EF.Conditional(progressNode < fadeThreshold, progressNode / fadeThreshold, 1); + + // Start opacity fade out animation on the additional text block visuals + subtitleVisual.StartAnimation("Opacity", textFadeAnimation); + textFadeAnimation.SetScalarParameter("fadeThreshold", 0.2f); + captionVisual.StartAnimation("Opacity", textFadeAnimation); + + // Get the backing visual for the button panel so that its properties can be animated + Visual buttonVisual = ElementCompositionPreview.GetElementVisual(ButtonPanel); + ElementCompositionPreview.SetIsTranslationEnabled(ButtonPanel, true); + + // Create and start an ExpressionAnimation to move the button panel with scroll position + ExpressionNode buttonTranslationAnimation = progressNode * (-buttonPanelOffsetNode); + buttonVisual.StartAnimation("Translation.Y", buttonTranslationAnimation); + } + + private void CoverArt_OnSizeChanged(object sender, SizeChangedEventArgs e) + { + _props?.InsertScalar("clampSize", ClampSize); + _props?.InsertScalar("backgroundScaleFactor", BackgroundScaleFactor); + _props?.InsertScalar("coverScaleFactor", CoverScaleFactor); + _props?.InsertScalar("buttonPanelOffset", ButtonPanelOffset); + } + + private void BackgroundHost_OnSizeChanged(object sender, SizeChangedEventArgs e) + { + if (_backgroundVisual == null) return; + _backgroundVisual.Size = new Vector2((float)e.NewSize.Width, BackgroundVisualHeight); + } + + private Thickness GetScrollbarVerticalMargin(Thickness value) + { + double headerHeight = CoverArt.Height + Header.Margin.Bottom; + return new Thickness(value.Left, value.Top - headerHeight, value.Right, value.Bottom); + } + + public static string GetSubtext(IReadOnlyCollection? items) + { + if (items == null) return string.Empty; + + int itemsCount = items.Count; + TimeSpan duration = GetTotalDuration(items); + + string itemsCountText = Strings.Resources.SongsCount(itemsCount); + string runTime = Strings.Resources.RunTime(Humanizer.ToDuration(duration)); + return $"{itemsCountText} • {runTime}"; + } + + public static TimeSpan GetTotalDuration(IReadOnlyCollection? items) + { + if (items == null) return TimeSpan.Zero; + + TimeSpan duration = TimeSpan.Zero; + foreach (MediaViewModel item in items) + { + duration += item.Duration; + } + + return duration; + } + + [RelayCommand] + private async Task RenamePlaylistAsync() + { + if (ViewModel.Source == null) return; + RenamePlaylistDialog dialog = new(ViewModel.Source.Name); + string? newName = await dialog.GetPlaylistNameAsync(); + if (!string.IsNullOrWhiteSpace(newName) && newName != ViewModel.Source.Name) + { + await ViewModel.Source.RenameAsync(newName!); + } + } + + [RelayCommand] + private async Task DeletePlaylistAsync() + { + if (ViewModel.Source == null) return; + var deleteConfirmation = new DeletePlaylistDialog(ViewModel.Source.Name); + var result = await deleteConfirmation.ShowAsync(); + if (result == ContentDialogResult.Primary) + { + bool deleted = await ViewModel.DeletePlaylistAsync(); + if (deleted && Frame.CanGoBack) + { + Frame.GoBack(); + } + } + } +} diff --git a/Screenbox/Pages/PlaylistsPage.xaml b/Screenbox/Pages/PlaylistsPage.xaml new file mode 100644 index 000000000..608eaa90a --- /dev/null +++ b/Screenbox/Pages/PlaylistsPage.xaml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Minimal + + + + + + + + + + + diff --git a/Screenbox/Pages/PlaylistsPage.xaml.cs b/Screenbox/Pages/PlaylistsPage.xaml.cs new file mode 100644 index 000000000..328e7d1d6 --- /dev/null +++ b/Screenbox/Pages/PlaylistsPage.xaml.cs @@ -0,0 +1,59 @@ +#nullable enable + +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.Mvvm.Input; +using Screenbox.Controls; +using Screenbox.Core.ViewModels; +using Windows.UI.Xaml; +using Windows.UI.Xaml.Controls; + +// 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 +{ + internal PlaylistsPageViewModel ViewModel => (PlaylistsPageViewModel)DataContext; + + internal CommonViewModel Common { get; } + + public PlaylistsPage() + { + this.InitializeComponent(); + DataContext = Ioc.Default.GetRequiredService(); + Common = Ioc.Default.GetRequiredService(); + } + + private async void HeaderCreateButton_OnClick(object sender, RoutedEventArgs e) + { + string? playlistName = await CreatePlaylistDialog.GetPlaylistNameAsync(); + if (!string.IsNullOrWhiteSpace(playlistName)) + { + await ViewModel.CreatePlaylistAsync(playlistName!); + } + } + + [RelayCommand] + private async Task RenamePlaylistAsync(PlaylistViewModel playlist) + { + RenamePlaylistDialog dialog = new(playlist.Name); + string? newName = await dialog.GetPlaylistNameAsync(); + if (!string.IsNullOrWhiteSpace(newName) && newName != playlist.Name) + { + await ViewModel.RenamePlaylistAsync(playlist, newName!); + } + } + + [RelayCommand] + private async Task DeletePlaylistAsync(PlaylistViewModel playlist) + { + var deleteConfirmation = new DeletePlaylistDialog(playlist.Name); + var result = await deleteConfirmation.ShowAsync(); + if (result == ContentDialogResult.Primary) + await ViewModel.DeletePlaylistAsync(playlist); + } +} diff --git a/Screenbox/Pages/SongsPage.xaml b/Screenbox/Pages/SongsPage.xaml index b65205dbf..840946118 100644 --- a/Screenbox/Pages/SongsPage.xaml +++ b/Screenbox/Pages/SongsPage.xaml @@ -28,7 +28,11 @@ TextTrimming="CharacterEllipsis" /> - + + + + + + + + + - + diff --git a/Screenbox/Pages/SongsPage.xaml.cs b/Screenbox/Pages/SongsPage.xaml.cs index 28fe47ec8..96dcca4be 100644 --- a/Screenbox/Pages/SongsPage.xaml.cs +++ b/Screenbox/Pages/SongsPage.xaml.cs @@ -1,121 +1,134 @@ -using CommunityToolkit.Mvvm.DependencyInjection; -using CommunityToolkit.WinUI; -using Microsoft.UI.Xaml.Controls; -using Screenbox.Core.ViewModels; +#nullable enable + using System.Collections.Generic; using System.ComponentModel; using System.Linq; +using CommunityToolkit.Mvvm.DependencyInjection; +using CommunityToolkit.WinUI; +using Microsoft.UI.Xaml.Controls; +using Screenbox.Core.ViewModels; using Windows.System; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Navigation; -// The Blank Page item template is documented at https://go.microsoft.com/fwlink/?LinkId=234238 +namespace Screenbox.Pages; -namespace Screenbox.Pages +/// +/// An empty page that can be used on its own or navigated to within a Frame. +/// +public sealed partial class SongsPage : Page { - /// - /// An empty page that can be used on its own or navigated to within a Frame. - /// - public sealed partial class SongsPage : Page - { - internal SongsPageViewModel ViewModel => (SongsPageViewModel)DataContext; + internal SongsPageViewModel ViewModel => (SongsPageViewModel)DataContext; - internal CommonViewModel Common { get; } + internal CommonViewModel Common { get; } - private double _contentVerticalOffset; + private double _contentVerticalOffset; - private readonly DispatcherQueue _dispatcherQueue; + private readonly DispatcherQueue _dispatcherQueue; - public SongsPage() - { - this.InitializeComponent(); - DataContext = Ioc.Default.GetRequiredService(); - Common = Ioc.Default.GetRequiredService(); - _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); - } + public SongsPage() + { + InitializeComponent(); + DataContext = Ioc.Default.GetRequiredService(); + Common = Ioc.Default.GetRequiredService(); + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + } - private void ViewModelOnPropertyChanged(object sender, PropertyChangedEventArgs e) + private void ViewModelOnPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SongsPageViewModel.SortBy)) { - if (e.PropertyName == nameof(SongsPageViewModel.SortBy)) - { - var state = ViewModel.SortBy switch - { - "album" => "SortByAlbum", - "artist" => "SortByArtist", - _ => "SortByTitle" - }; - VisualStateManager.GoToState(this, state, true); - UpdateSortByFlyout(); - SavePageState(0); - } + return; } - protected override void OnNavigatedTo(NavigationEventArgs e) + var state = ViewModel.SortBy switch { - base.OnNavigatedTo(e); - if (e.NavigationMode == NavigationMode.Back - && Common.TryGetPageState(nameof(SongsPage), Frame.BackStackDepth, out var state) - && state is KeyValuePair pair) - { - ViewModel.SortBy = pair.Key; - _contentVerticalOffset = pair.Value; - } - - if (!_dispatcherQueue.TryEnqueue(ViewModel.FetchSongs)) - ViewModel.FetchSongs(); - - ViewModel.PropertyChanged += ViewModelOnPropertyChanged; - } + "album" => "SortByAlbum", + "artist" => "SortByArtist", + _ => "SortByTitle" + }; + + VisualStateManager.GoToState(this, state, true); + UpdateSortByFlyout(); + SavePageState(0); + } - protected override void OnNavigatedFrom(NavigationEventArgs e) - { - base.OnNavigatedFrom(e); - ViewModel.OnNavigatedFrom(); - ViewModel.PropertyChanged -= ViewModelOnPropertyChanged; - } + protected override void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); - private void SongListView_OnLoaded(object sender, RoutedEventArgs e) + if (e.NavigationMode == NavigationMode.Back + && Common.TryGetPageState(nameof(SongsPage), Frame.BackStackDepth, out var state) + && state is KeyValuePair pair) { - ScrollViewer? scrollViewer = SongListView.FindDescendant(); - if (scrollViewer == null) return; - scrollViewer.ViewChanging += ScrollViewerOnViewChanging; - if (_contentVerticalOffset > 0) - { - scrollViewer.ChangeView(null, _contentVerticalOffset, null, true); - } + ViewModel.SortBy = pair.Key; + _contentVerticalOffset = pair.Value; } - private void ScrollViewerOnViewChanging(object sender, ScrollViewerViewChangingEventArgs e) + if (!_dispatcherQueue.TryEnqueue(ViewModel.FetchSongs)) { - SavePageState(e.NextView.VerticalOffset); + ViewModel.FetchSongs(); } - private void SavePageState(double verticalOffset) - { - Common.SavePageState(new KeyValuePair(ViewModel.SortBy, verticalOffset), nameof(SongsPage), - Frame.BackStackDepth); - } + ViewModel.PropertyChanged += ViewModelOnPropertyChanged; + } - private string GetSortByText(string tag) + protected override void OnNavigatedFrom(NavigationEventArgs e) + { + base.OnNavigatedFrom(e); + + ViewModel.OnNavigatedFrom(); + ViewModel.PropertyChanged -= ViewModelOnPropertyChanged; + } + + private void SongListView_OnLoaded(object sender, RoutedEventArgs e) + { + ScrollViewer? scrollViewer = SongListView.FindDescendant(); + if (scrollViewer is null) { - var item = SortByFlyout.Items?.FirstOrDefault(x => x.Tag as string == tag) ?? SortByFlyout.Items?.FirstOrDefault(); - return (item as MenuFlyoutItem)?.Text ?? string.Empty; + return; } - private string GetSortByButtonAutomationName(string value) + scrollViewer.ViewChanging += ScrollViewerOnViewChanging; + + if (_contentVerticalOffset <= 0) { - var optionText = GetSortByText(value); - return Strings.Resources.SortByAutomationName(optionText); + return; } - private void UpdateSortByFlyout() + scrollViewer.ChangeView(null, _contentVerticalOffset, null, true); + } + + private void ScrollViewerOnViewChanging(object sender, ScrollViewerViewChangingEventArgs e) + { + SavePageState(e.NextView.VerticalOffset); + } + + private void SavePageState(double verticalOffset) + { + Common.SavePageState(new KeyValuePair(ViewModel.SortBy, verticalOffset), nameof(SongsPage), Frame.BackStackDepth); + } + + private string GetSortByText(string tag) + { + var item = SortByFlyout.Items?.FirstOrDefault(x => (x.Tag as string) == tag) ?? SortByFlyout.Items?.FirstOrDefault(); + return (item as MenuFlyoutItem)?.Text ?? string.Empty; + } + + private string GetSortByButtonAutomationName(string value) + { + var optionText = GetSortByText(value); + return Strings.Resources.SortByAutomationName(optionText); + } + + private void UpdateSortByFlyout() + { + if ((SortByFlyout.Items?.FirstOrDefault(x => (x.Tag as string) == ViewModel.SortBy) ?? SortByFlyout.Items?.FirstOrDefault()) is not RadioMenuFlyoutItem radioItem) { - if ((SortByFlyout.Items?.FirstOrDefault(x => x.Tag as string == ViewModel.SortBy) ?? - SortByFlyout.Items?.FirstOrDefault()) is RadioMenuFlyoutItem radioItem) - { - radioItem.IsChecked = true; - } + return; } + + radioItem.IsChecked = true; } } diff --git a/Screenbox/Screenbox.csproj b/Screenbox/Screenbox.csproj index e92b54e01..6f2ca214c 100644 --- a/Screenbox/Screenbox.csproj +++ b/Screenbox/Screenbox.csproj @@ -135,6 +135,7 @@ App.xaml + @@ -167,7 +168,13 @@ CommonGridViewItem.xaml + + CreatePlaylistDialog.xaml + + + DeletePlaylistDialog.xaml + ErrorInfo.xaml @@ -203,6 +210,9 @@ PropertiesView.xaml + + RenamePlaylistDialog.xaml + SeekBar.xaml @@ -243,6 +253,12 @@ AlbumDetailsPage.xaml + + PlaylistDetailsPage.xaml + + + PlaylistsPage.xaml + AlbumSearchResultPage.xaml @@ -541,6 +557,14 @@ Designer MSBuild:Compile + + MSBuild:Compile + Designer + + + Designer + MSBuild:Compile + Designer MSBuild:Compile @@ -585,6 +609,10 @@ Designer MSBuild:Compile + + MSBuild:Compile + Designer + Designer MSBuild:Compile @@ -613,6 +641,14 @@ Designer MSBuild:Compile + + MSBuild:Compile + Designer + + + Designer + MSBuild:Compile + Designer MSBuild:Compile diff --git a/Screenbox/Strings/en-US/Resources.resw b/Screenbox/Strings/en-US/Resources.resw index 7a8f5bb6c..be0691094 100644 --- a/Screenbox/Strings/en-US/Resources.resw +++ b/Screenbox/Strings/en-US/Resources.resw @@ -978,4 +978,56 @@ Folder + + Add to playlist + + + Create new playlist + + + New playlist + + + Selected item + + + Selected items + + + No playlists + + + Create + + + Cancel + + + Playlist name + + + Enter a name for this playlist + + + Playlists + + + Delete + + + Rename + + + Enter a new name for this playlist + + + Rename playlist + + + Delete playlist + + + Are you sure you want to delete '{0}' playlist? This action cannot be undone. + #Format[String playlistName] + \ No newline at end of file