diff --git a/Source/AppScaffolding/Ensure.cs b/Source/AppScaffolding/Ensure.cs new file mode 100644 index 000000000..67c8c5ff4 --- /dev/null +++ b/Source/AppScaffolding/Ensure.cs @@ -0,0 +1,97 @@ +using System; + +namespace AppScaffolding; + +/// +/// A helper class for ensuring parameter conditions. +/// Yoinked from https://github.com/CoryCharlton/CCSWE.Core/blob/master/src/Core/Ensure.cs +/// +public static class Ensure +{ + private static Exception GetException(string name, string message) where TException : Exception, new() + { + Exception exception = (TException)Activator.CreateInstance(typeof(TException), message); + + if (exception is ArgumentNullException) + { + return new ArgumentNullException(name, message); + } + + if (exception is ArgumentOutOfRangeException) + { + return new ArgumentOutOfRangeException(name, message); + } + + if (exception is ArgumentException) + { + return new ArgumentException(message, name); + } + + return exception; + } + + /// + /// Throws an if the expression evaluates to false. + /// + /// The name of the parameter we are validating. + /// The expression that will be evaluated. + /// The message associated with the + /// Thrown when the expression evaluates to false. + public static void IsInRange(string name, bool expression, string message = null) + { + IsValid(name, expression, string.IsNullOrWhiteSpace(message) ? $"The value passed for '{name}' is out of range." : message); + } + + /// + /// Throws an if the value is null. + /// + /// The name of the parameter we are validating. + /// The value that will be evaluated. + /// The message associated with the + /// Thrown when the value is null. + public static void IsNotNull(string name, T value, string message = null) + { + IsValid(name, value != null, string.IsNullOrWhiteSpace(message) ? $"The value passed for '{name}' is null." : message); + } + + /// + /// Throws an if the value is null or whitespace. + /// + /// The name of the parameter we are validating. + /// The value that will be evaluated. + /// The message associated with the + /// Thrown when the value is null or whitespace.. + public static void IsNotNullOrWhitespace(string name, string value, string message = null) + { + IsValid(name, !string.IsNullOrWhiteSpace(value), string.IsNullOrWhiteSpace(message) ? $"The value passed for '{name}' is empty, null, or whitespace." : message); + } + + /// + /// Throws an if the expression evaluates to false. + /// + /// The name of the parameter we are validating. + /// The expression that will be evaluated. + /// The message associated with the + /// Thrown when the expression evaluates to false. + public static void IsValid(string name, bool expression, string message = null) + { + IsValid(name, expression, string.IsNullOrWhiteSpace(message) ? $"The value passed for '{name}' is not valid." : message); + } + + /// + /// Throws an exception if the expression evaluates to false. + /// + /// The type of to throw. + /// The name of the parameter we are validating. + /// The expression that will be evaluated. + /// The message associated with the + public static void IsValid(string name, bool expression, string message = null) where TException : Exception, new() + { + if (expression) + { + return; + } + + throw GetException(name, string.IsNullOrWhiteSpace(message) ? $"The value passed for '{name}' is not valid." : message); + } +} \ No newline at end of file diff --git a/Source/AppScaffolding/SynchronizedObservableCollection.cs b/Source/AppScaffolding/SynchronizedObservableCollection.cs new file mode 100644 index 000000000..7675fa29c --- /dev/null +++ b/Source/AppScaffolding/SynchronizedObservableCollection.cs @@ -0,0 +1,774 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; + +namespace AppScaffolding; + +/// Represents a thread-safe dynamic data collection +/// that provides notifications when items get added, removed, +/// or when the whole list is refreshed. +/// Yoinked from: https://github.com/CoryCharlton/CCSWE.Core/blob/master/src/Core/Collections/ObjectModel/SynchronizedObservableCollection%601.cs +/// The type of elements in the collection. +#if NETSTANDARD2_0 || NETFULL + [Serializable] +#endif +[ComVisible(false)] +[DebuggerDisplay("Count = {Count}")] +public class SynchronizedObservableCollection : IDisposable, IList, IList, IReadOnlyList, INotifyCollectionChanged, INotifyPropertyChanged +{ + /// Initializes a new instance of the class. + public SynchronizedObservableCollection() : this(new List(), GetCurrentSynchronizationContext()) + { + } + + /// Initializes a new instance of the class that contains elements copied from the specified collection. + /// The collection from which the elements are copied. + /// The parameter cannot be null. + [SuppressMessage("ReSharper", "PossibleMultipleEnumeration")] + public SynchronizedObservableCollection(IEnumerable collection) : this(collection, GetCurrentSynchronizationContext()) + { + } + + /// Initializes a new instance of the class with the specified context. + /// The context used for event invokation. + /// The parameter cannot be null. + public SynchronizedObservableCollection(SynchronizationContext context) : this(new List(), context) + { + } + + /// Initializes a new instance of the class that contains elements copied from the specified collection with the specified context. + /// The collection from which the elements are copied. + /// The context used for event invokation. + /// The parameter cannot be null. + /// The parameter cannot be null. + [SuppressMessage("ReSharper", "PossibleMultipleEnumeration")] + public SynchronizedObservableCollection(IEnumerable collection, SynchronizationContext context) + { + Ensure.IsNotNull(nameof(collection), collection); + Ensure.IsNotNull(nameof(context), context); + + _context = context; + + foreach (var item in collection) + { + _items.Add(item); + } + } + +#if NETSTANDARD2_0 || NETFULL + [NonSerialized] +#endif + private readonly SynchronizationContext _context; + private bool _isDisposed; + private readonly IList _items = new List(); +#if NETSTANDARD2_0 || NETFULL + [NonSerialized] +#endif + private readonly ReaderWriterLockSlim _itemsLocker = new ReaderWriterLockSlim(); +#if NETSTANDARD2_0 || NETFULL + [NonSerialized] +#endif + private readonly SimpleMonitor _monitor = new SimpleMonitor(); +#if NETSTANDARD2_0 || NETFULL + [NonSerialized] +#endif + private object _syncRoot; + + event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged + { + add { PropertyChanged += value; } + remove { PropertyChanged -= value; } + } + + /// Occurs when an item is added, removed, changed, moved, or the entire list is refreshed. + public event NotifyCollectionChangedEventHandler CollectionChanged; + + /// Occurs when a property value changes. + protected event PropertyChangedEventHandler PropertyChanged; + + /// Gets a value indicating whether the . + /// true if the has a fixed size; otherwise, false. + protected bool IsFixedSize => false; + + bool IList.IsFixedSize => IsFixedSize; + + /// Gets a value indicating whether the is read-only. + /// true if the is read-only; otherwise, false. + protected bool IsReadOnly => false; + + bool ICollection.IsReadOnly => IsReadOnly; + + bool IList.IsReadOnly => IsReadOnly; + + /// Gets a value indicating whether access to the is synchronized (thread safe). + /// true if access to the is synchronized (thread safe); otherwise, false. + protected bool IsSynchronized => true; + + bool ICollection.IsSynchronized => IsSynchronized; + + /// Gets an object that can be used to synchronize access to the . + /// An object that can be used to synchronize access to the . + protected object SyncRoot + { + get + { + // ReSharper disable once InvertIf + if (_syncRoot == null) + { + _itemsLocker.EnterReadLock(); + + try + { + var collection = _items as ICollection; + if (collection != null) + { + _syncRoot = collection.SyncRoot; + } + else + { + Interlocked.CompareExchange(ref _syncRoot, new object(), null); + } + } + finally + { + _itemsLocker.ExitReadLock(); + } + } + + return _syncRoot; + } + } + + object ICollection.SyncRoot => SyncRoot; + + object IList.this[int index] + { + get { return this[index]; } + set + { + try + { + this[index] = (T)value; + } + catch (InvalidCastException) + { + throw new ArgumentException("'value' is the wrong type"); + } + } + } + + /// + /// Gets the that events will be invoked on. + /// + // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter + public SynchronizationContext Context => _context; + + /// Gets the number of elements actually contained in the . + /// The number of elements actually contained in the . + public int Count + { + get + { + _itemsLocker.EnterReadLock(); + + try + { + return _items.Count; + } + finally + { + _itemsLocker.ExitReadLock(); + } + } + } + + /// Gets or sets the element at the specified index. + /// The element at the specified index. + /// The zero-based index of the element to get or set. + /// + /// is less than zero.-or- is equal to or greater than . + public T this[int index] + { + get + { + _itemsLocker.EnterReadLock(); + + try + { + CheckIndex(index); + + return _items[index]; + } + finally + { + _itemsLocker.ExitReadLock(); + } + } + set + { + T oldValue; + + _itemsLocker.EnterWriteLock(); + + try + { + CheckIndex(index); + CheckReentrancy(); + + oldValue = _items[index]; + + _items[index] = value; + + } + finally + { + _itemsLocker.ExitWriteLock(); + } + + OnNotifyItemReplaced(value, oldValue, index); + } + } + + private IDisposable BlockReentrancy() + { + _monitor.Enter(); + + return _monitor; + } + + // ReSharper disable once UnusedParameter.Local + private void CheckIndex(int index) + { + if (index < 0 || index >= _items.Count) + { + throw new ArgumentOutOfRangeException(); + } + } + + private void CheckReentrancy() + { + if (_monitor.Busy && CollectionChanged != null && CollectionChanged.GetInvocationList().Length > 1) + { + throw new InvalidOperationException("SynchronizedObservableCollection reentrancy not allowed"); + } + } + + private static SynchronizationContext GetCurrentSynchronizationContext() + { + return SynchronizationContext.Current ?? new SynchronizationContext(); + } + + private static bool IsCompatibleObject(object value) + { + // Non-null values are fine. Only accept nulls if T is a class or Nullable. + // Note that default(T) is not equal to null for value types except when T is Nullable. + return ((value is T) || (value == null && default(T) == null)); + } + + private void OnNotifyCollectionReset() + { + using (BlockReentrancy()) + { + _context.Send(state => + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Count")); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + }, null); + } + } + + private void OnNotifyItemAdded(T item, int index) + { + using (BlockReentrancy()) + { + _context.Send(state => + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Count")); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index)); + }, null); + } + } + + private void OnNotifyItemMoved(T item, int newIndex, int oldIndex) + { + using (BlockReentrancy()) + { + _context.Send(state => + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Move, item, newIndex, oldIndex)); + }, null); + } + } + + private void OnNotifyItemRemoved(T item, int index) + { + using (BlockReentrancy()) + { + _context.Send(state => + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Count")); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, index)); + }, null); + } + } + + private void OnNotifyItemReplaced(T newItem, T oldItem, int index) + { + using (BlockReentrancy()) + { + _context.Send(state => + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newItem, oldItem, index)); + }, null); + } + } + + /// + /// Releases all resources used by the . + /// + /// Not used. + protected virtual void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + _itemsLocker.Dispose(); + _isDisposed = true; + } + + /// Adds an object to the end of the . + /// The object to be added to the end of the . The value can be null for reference types. + public void Add(T item) + { + _itemsLocker.EnterWriteLock(); + + int index; + + try + { + CheckReentrancy(); + + index = _items.Count; + + _items.Insert(index, item); + } + finally + { + _itemsLocker.ExitWriteLock(); + } + + OnNotifyItemAdded(item, index); + } + + int IList.Add(object value) + { + _itemsLocker.EnterWriteLock(); + + int index; + T item; + + try + { + CheckReentrancy(); + + index = _items.Count; + item = (T)value; + + _items.Insert(index, item); + } + catch (InvalidCastException) + { + throw new ArgumentException("'value' is the wrong type"); + } + finally + { + _itemsLocker.ExitWriteLock(); + } + + OnNotifyItemAdded(item, index); + + return index; + } + + /// Removes all elements from the . + public void Clear() + { + _itemsLocker.EnterWriteLock(); + + try + { + CheckReentrancy(); + + _items.Clear(); + } + finally + { + _itemsLocker.ExitWriteLock(); + } + + OnNotifyCollectionReset(); + } + + /// Copies the elements to an existing one-dimensional , starting at the specified array index. + /// The one-dimensional that is the destination of the elements copied from . The must have zero-based indexing. + /// The zero-based index in at which copying begins. + /// + /// is null. + /// + /// is less than zero. + /// The number of elements in the source is greater than the available space from to the end of the destination . + public void CopyTo(T[] array, int arrayIndex) + { + Ensure.IsNotNull(nameof(array), array); + Ensure.IsInRange(nameof(arrayIndex), arrayIndex >= 0 && arrayIndex < array.Length); + Ensure.IsValid(nameof(arrayIndex), array.Length - arrayIndex >= Count, "Invalid offset length."); + + _itemsLocker.EnterReadLock(); + + try + { + _items.CopyTo(array, arrayIndex); + } + finally + { + _itemsLocker.ExitReadLock(); + } + } + + void ICollection.CopyTo(Array array, int arrayIndex) + { + Ensure.IsNotNull(nameof(array), array); + Ensure.IsValid(nameof(array), array.Rank == 1, "Multidimensional array are not supported"); + Ensure.IsValid(nameof(array), array.GetLowerBound(0) == 0, "Non-zero lower bound is not supported"); + Ensure.IsInRange(nameof(arrayIndex), arrayIndex >= 0 && arrayIndex < array.Length); + Ensure.IsValid(nameof(arrayIndex), array.Length - arrayIndex >= Count, "Invalid offset length."); + + _itemsLocker.EnterReadLock(); + + try + { + var tArray = array as T[]; + if (tArray != null) + { + _items.CopyTo(tArray, arrayIndex); + } + else + { + +#if NETSTANDARD2_0 || NETFULL + // + // Catch the obvious case assignment will fail. + // We can found all possible problems by doing the check though. + // For example, if the element type of the Array is derived from T, + // we can't figure out if we can successfully copy the element beforehand. + // + var targetType = array.GetType().GetElementType(); + var sourceType = typeof (T); + if (!(targetType.IsAssignableFrom(sourceType) || sourceType.IsAssignableFrom(targetType))) + { + throw new ArrayTypeMismatchException("Invalid array type"); + } +#endif + + // + // We can't cast array of value type to object[], so we don't support + // widening of primitive types here. + // + var objects = array as object[]; + if (objects == null) + { + throw new ArrayTypeMismatchException("Invalid array type"); + } + + var count = _items.Count; + try + { + for (var i = 0; i < count; i++) + { + objects[arrayIndex++] = _items[i]; + } + } + catch (ArrayTypeMismatchException) + { + throw new ArrayTypeMismatchException("Invalid array type"); + } + } + } + finally + { + _itemsLocker.ExitReadLock(); + } + } + + /// Determines whether an element is in the . + /// true if is found in the ; otherwise, false. + /// The object to locate in the . The value can be null for reference types. + public bool Contains(T item) + { + _itemsLocker.EnterReadLock(); + + try + { + return _items.Contains(item); + } + finally + { + _itemsLocker.ExitReadLock(); + } + } + + bool IList.Contains(object value) + { + if (!IsCompatibleObject(value)) + { + return false; + } + + _itemsLocker.EnterReadLock(); + + try + { + return _items.Contains((T)value); + } + finally + { + _itemsLocker.ExitReadLock(); + } + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Returns an enumerator that iterates through the . + /// An for the . + public IEnumerator GetEnumerator() + { + _itemsLocker.EnterReadLock(); + + try + { + return _items.ToList().GetEnumerator(); + } + finally + { + _itemsLocker.ExitReadLock(); + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + // ReSharper disable once RedundantCast + return (IEnumerator)GetEnumerator(); + } + + /// Searches for the specified object and returns the zero-based index of the first occurrence within the entire . + /// The zero-based index of the first occurrence of within the entire , if found; otherwise, -1. + /// The object to locate in the . The value can be null for reference types. + public int IndexOf(T item) + { + _itemsLocker.EnterReadLock(); + + try + { + return _items.IndexOf(item); + } + finally + { + _itemsLocker.ExitReadLock(); + } + } + + int IList.IndexOf(object value) + { + if (!IsCompatibleObject(value)) + { + return -1; + } + + _itemsLocker.EnterReadLock(); + + try + { + return _items.IndexOf((T)value); + } + finally + { + _itemsLocker.ExitReadLock(); + } + } + + /// Inserts an element into the at the specified index. + /// The zero-based index at which should be inserted. + /// The object to insert. The value can be null for reference types. + /// + /// is less than zero.-or- is greater than . + public void Insert(int index, T item) + { + _itemsLocker.EnterWriteLock(); + + try + { + CheckReentrancy(); + + if (index < 0 || index > _items.Count) + { + throw new ArgumentOutOfRangeException(); + } + + _items.Insert(index, item); + } + finally + { + _itemsLocker.ExitWriteLock(); + } + + OnNotifyItemAdded(item, index); + } + + void IList.Insert(int index, object value) + { + try + { + Insert(index, (T)value); + } + catch (InvalidCastException) + { + throw new ArgumentException("'value' is the wrong type"); + } + } + + /// Moves the item at the specified index to a new location in the collection. + /// The zero-based index specifying the location of the item to be moved. + /// The zero-based index specifying the new location of the item. + public void Move(int oldIndex, int newIndex) + { + T item; + + _itemsLocker.EnterWriteLock(); + + try + { + CheckReentrancy(); + CheckIndex(oldIndex); + CheckIndex(newIndex); + + item = _items[oldIndex]; + + _items.RemoveAt(oldIndex); + _items.Insert(newIndex, item); + } + finally + { + _itemsLocker.ExitWriteLock(); + } + + OnNotifyItemMoved(item, newIndex, oldIndex); + } + + /// Removes the first occurrence of a specific object from the . + /// true if is successfully removed; otherwise, false. This method also returns false if was not found in the original . + /// The object to remove from the . The value can be null for reference types. + public bool Remove(T item) + { + int index; + T value; + + _itemsLocker.EnterWriteLock(); + + try + { + CheckReentrancy(); + + index = _items.IndexOf(item); + + if (index < 0) + { + return false; + } + + value = _items[index]; + + _items.RemoveAt(index); + } + finally + { + _itemsLocker.ExitWriteLock(); + } + + OnNotifyItemRemoved(value, index); + + return true; + } + + void IList.Remove(object value) + { + if (IsCompatibleObject(value)) + { + Remove((T)value); + } + } + + /// Removes the element at the specified index of the . + /// The zero-based index of the element to remove. + /// + /// is less than zero.-or- is equal to or greater than . + public void RemoveAt(int index) + { + T value; + + _itemsLocker.EnterWriteLock(); + + try + { + CheckIndex(index); + CheckReentrancy(); + + value = _items[index]; + + _items.RemoveAt(index); + } + finally + { + _itemsLocker.ExitWriteLock(); + } + + OnNotifyItemRemoved(value, index); + } + + private class SimpleMonitor : IDisposable + { + private int _busyCount; + + public bool Busy => _busyCount > 0; + + public void Enter() + { + ++_busyCount; + } + + public void Dispose() + { + --_busyCount; + } + } +} \ No newline at end of file diff --git a/Source/FileManager/BackgroundFileSystem.cs b/Source/FileManager/BackgroundFileSystem.cs index e4ec7db20..fc3e10337 100644 --- a/Source/FileManager/BackgroundFileSystem.cs +++ b/Source/FileManager/BackgroundFileSystem.cs @@ -72,8 +72,8 @@ private void Init() fileSystemWatcher.Renamed += FileSystemWatcher_Changed; fileSystemWatcher.Error += FileSystemWatcher_Error; - backgroundScanner = new Task(BackgroundScanner); - backgroundScanner.Start(); + backgroundScanner = Task.Factory.StartNew(BackgroundScanner, TaskCreationOptions.LongRunning); + //backgroundScanner.Start(); } private void Stop() { diff --git a/Source/Libation.sln.startup.json b/Source/Libation.sln.startup.json new file mode 100644 index 000000000..e4def5e0a --- /dev/null +++ b/Source/Libation.sln.startup.json @@ -0,0 +1,24 @@ +/* + This is a configuration file for the SwitchStartupProject Visual Studio Extension + See https://github.com/ernstc/SwitchStartupProject2022/blob/main/Configuration.md +*/ +{ + /* Configuration File Version */ + "Version": 3, + + /* Create an item in the dropdown list for each project in the solution? */ + "ListAllProjects": false, + + "MultiProjectConfigurations": { + "WinForms": { + "Projects": { + "LibationWinForms": {} + } + }, + "Avalonia": { + "Projects": { + "LibationAvalonia": {} + } + } + } +} diff --git a/Source/LibationAvalonia/App.axaml b/Source/LibationAvalonia/App.axaml index 5ba253c07..444237d30 100644 --- a/Source/LibationAvalonia/App.axaml +++ b/Source/LibationAvalonia/App.axaml @@ -1,92 +1,113 @@ - + - - - + + + - + - - - - - - - - - - - + + + + + + + + + + + - - - - - + + + + + - + - - - - - - - - - - + + + + + + + + + + - - - - - + + + + + - - - - - - + - - - - - - + + + + - - - - - - + + + + + + - - - - - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/LibationAvalonia/App.axaml.cs b/Source/LibationAvalonia/App.axaml.cs index 59fe3f75b..7d65996ee 100644 --- a/Source/LibationAvalonia/App.axaml.cs +++ b/Source/LibationAvalonia/App.axaml.cs @@ -2,232 +2,256 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Input; using Avalonia.Markup.Xaml; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Styling; using LibationAvalonia.Dialogs; +using LibationAvalonia.ViewModels; using LibationAvalonia.Views; using LibationFileManager; +using LibationUiBase; +using LibationUiBase.ViewModels; +using LibationUiBase.ViewModels.Player; using System; using System.Collections.Generic; using System.IO; +using System.Reflection; using System.Threading.Tasks; -using ReactiveUI; -using DataLayer; +using ViewModelBase = LibationAvalonia.ViewModels.ViewModelBase; namespace LibationAvalonia { - public class App : Application - { - public static Window MainWindow { get; private set; } - public static IBrush ProcessQueueBookFailedBrush { get; private set; } - public static IBrush ProcessQueueBookCompletedBrush { get; private set; } - public static IBrush ProcessQueueBookCancelledBrush { get; private set; } - public static IBrush ProcessQueueBookDefaultBrush { get; private set; } - public static IBrush SeriesEntryGridBackgroundBrush { get; private set; } - - public static readonly Uri AssetUriBase = new("avares://Libation/Assets/"); - public static Stream OpenAsset(string assetRelativePath) - => AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath)); - - public override void Initialize() - { - AvaloniaXamlLoader.Load(this); - } - - public static Task> LibraryTask; - - public override void OnFrameworkInitializationCompleted() - { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - var config = Configuration.Instance; - - if (!config.LibationSettingsAreValid) - { - var defaultLibationFilesDir = Configuration.UserProfile; - - // check for existing settings in default location - var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); - if (Configuration.SettingsFileIsValid(defaultSettingsFile)) - Configuration.SetLibationFiles(defaultLibationFilesDir); - - if (config.LibationSettingsAreValid) - { - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - ShowMainWindow(desktop); - } - else - { - var setupDialog = new SetupDialog { Config = config }; - setupDialog.Closing += Setup_Closing; - desktop.MainWindow = setupDialog; - } - } - else - ShowMainWindow(desktop); - } - - base.OnFrameworkInitializationCompleted(); - } - - private async void Setup_Closing(object sender, System.ComponentModel.CancelEventArgs e) - { - var setupDialog = sender as SetupDialog; - var desktop = ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; - - try - { - // all returns should be preceded by either: - // - if config.LibationSettingsAreValid - // - error message, Exit() - if (setupDialog.IsNewUser) - { - Configuration.SetLibationFiles(Configuration.UserProfile); - setupDialog.Config.Books = Path.Combine(Configuration.UserProfile, nameof(Configuration.Books)); - - if (setupDialog.Config.LibationSettingsAreValid) - { + public class App : Application + { + public static Window MainWindow { get; private set; } + public static IBrush ProcessQueueBookFailedBrush { get; private set; } + public static IBrush ProcessQueueBookCompletedBrush { get; private set; } + public static IBrush ProcessQueueBookCancelledBrush { get; private set; } + public static IBrush ProcessQueueBookDefaultBrush { get; private set; } + public static IBrush SeriesEntryGridBackgroundBrush { get; private set; } + + public static readonly Uri AssetUriBase = new("avares://Libation/Assets/"); + public static Stream OpenAsset(string assetRelativePath) + => AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath)); + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public static Task> LibraryTask; + + public override void OnFrameworkInitializationCompleted() + { + RegisterTypes(); + + ServiceLocator.AddCommonServicesAndBuild(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var config = Configuration.Instance; + + if (!config.LibationSettingsAreValid) + { + var defaultLibationFilesDir = Configuration.UserProfile; + + // check for existing settings in default location + var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); + if (Configuration.SettingsFileIsValid(defaultSettingsFile)) + Configuration.SetLibationFiles(defaultLibationFilesDir); + + if (config.LibationSettingsAreValid) + { + LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + ShowMainWindow(desktop); + } + else + { + var setupDialog = new SetupDialog { Config = config }; + setupDialog.Closing += Setup_Closing; + desktop.MainWindow = setupDialog; + } + } + else + ShowMainWindow(desktop); + } + + base.OnFrameworkInitializationCompleted(); + } + + private async void Setup_Closing(object sender, System.ComponentModel.CancelEventArgs e) + { + var setupDialog = sender as SetupDialog; + var desktop = ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + + try + { + // all returns should be preceded by either: + // - if config.LibationSettingsAreValid + // - error message, Exit() + if (setupDialog.IsNewUser) + { + Configuration.SetLibationFiles(Configuration.UserProfile); + setupDialog.Config.Books = Path.Combine(Configuration.UserProfile, nameof(Configuration.Books)); + + if (setupDialog.Config.LibationSettingsAreValid) + { string theme = setupDialog.SelectedTheme.Content as string; - - setupDialog.Config.SetString(theme, nameof(ThemeVariant)); - - await RunMigrationsAsync(setupDialog.Config); - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); - ShowMainWindow(desktop); - } - else - await CancelInstallation(); - } - else if (setupDialog.IsReturningUser) - { - ShowLibationFilesDialog(desktop, setupDialog.Config, OnLibationFilesCompleted); - } - else - { - await CancelInstallation(); - return; - } - - } - catch (Exception ex) - { - var title = "Fatal error, pre-logging"; - var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; - try - { - await MessageBox.ShowAdminAlert(null, body, title, ex); - } - catch - { - await MessageBox.Show($"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); - } - return; - } - } - - private async Task RunMigrationsAsync(Configuration config) - { - // most migrations go in here - AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config); - - await MessageBox.VerboseLoggingWarning_ShowIfTrue(); - - // logging is init'd here - AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(AppScaffolding.Variety.Chardonnay, config); - } - - private void ShowLibationFilesDialog(IClassicDesktopStyleApplicationLifetime desktop, Configuration config, Action OnClose) - { - var libationFilesDialog = new LibationFilesDialog(); - desktop.MainWindow = libationFilesDialog; - libationFilesDialog.Show(); - - void WindowClosing(object sender, System.ComponentModel.CancelEventArgs e) - { - libationFilesDialog.Closing -= WindowClosing; - e.Cancel = true; - OnClose?.Invoke(desktop, libationFilesDialog, config); - } - libationFilesDialog.Closing += WindowClosing; - } - - private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config) - { - Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory); - if (config.LibationSettingsAreValid) - { - await RunMigrationsAsync(config); - - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); - ShowMainWindow(desktop); - } - else - { - // path did not result in valid settings - var continueResult = await MessageBox.Show( - $"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}", - "New install?", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question); - - if (continueResult == DialogResult.Yes) - { - config.Books = Path.Combine(libationFilesDialog.SelectedDirectory, nameof(Configuration.Books)); - - if (config.LibationSettingsAreValid) - { - await RunMigrationsAsync(config); - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); - ShowMainWindow(desktop); - } - else - await CancelInstallation(); - } - else - await CancelInstallation(); - } - - libationFilesDialog.Close(); - } - - static async Task CancelInstallation() - { - await MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); - Environment.Exit(0); - } - - private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop) - { + + setupDialog.Config.SetString(theme, nameof(ThemeVariant)); + + await RunMigrationsAsync(setupDialog.Config); + LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); + ShowMainWindow(desktop); + } + else + await CancelInstallation(); + } + else if (setupDialog.IsReturningUser) + { + ShowLibationFilesDialog(desktop, setupDialog.Config, OnLibationFilesCompleted); + } + else + { + await CancelInstallation(); + return; + } + + } + catch (Exception ex) + { + var title = "Fatal error, pre-logging"; + var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; + try + { + await MessageBox.ShowAdminAlert(null, body, title, ex); + } + catch + { + await MessageBox.Show($"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + return; + } + } + + private async Task RunMigrationsAsync(Configuration config) + { + // most migrations go in here + AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config); + + await MessageBox.VerboseLoggingWarning_ShowIfTrue(); + + // logging is init'd here + AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(AppScaffolding.Variety.Chardonnay, config); + } + + private void ShowLibationFilesDialog(IClassicDesktopStyleApplicationLifetime desktop, Configuration config, Action OnClose) + { + var libationFilesDialog = new LibationFilesDialog(); + desktop.MainWindow = libationFilesDialog; + libationFilesDialog.Show(); + + void WindowClosing(object sender, System.ComponentModel.CancelEventArgs e) + { + libationFilesDialog.Closing -= WindowClosing; + e.Cancel = true; + OnClose?.Invoke(desktop, libationFilesDialog, config); + } + libationFilesDialog.Closing += WindowClosing; + } + + private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config) + { + Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory); + if (config.LibationSettingsAreValid) + { + await RunMigrationsAsync(config); + + LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); + ShowMainWindow(desktop); + } + else + { + // path did not result in valid settings + var continueResult = await MessageBox.Show( + $"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}", + "New install?", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + + if (continueResult == DialogResult.Yes) + { + config.Books = Path.Combine(libationFilesDialog.SelectedDirectory, nameof(Configuration.Books)); + + if (config.LibationSettingsAreValid) + { + await RunMigrationsAsync(config); + LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); + ShowMainWindow(desktop); + } + else + await CancelInstallation(); + } + else + await CancelInstallation(); + } + + libationFilesDialog.Close(); + } + + static async Task CancelInstallation() + { + await MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); + Environment.Exit(0); + } + + private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop) + { Current.RequestedThemeVariant = Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) switch { nameof(ThemeVariant.Dark) => ThemeVariant.Dark, nameof(ThemeVariant.Light) => ThemeVariant.Light, - // "System" + // "System" _ => ThemeVariant.Default }; //Reload colors for current theme - LoadStyles(); - var mainWindow = new MainWindow(); - desktop.MainWindow = MainWindow = mainWindow; - mainWindow.OnLibraryLoaded(LibraryTask.GetAwaiter().GetResult()); - mainWindow.RestoreSizeAndLocation(Configuration.Instance); - mainWindow.Show(); - } - - private static void LoadStyles() - { - ProcessQueueBookFailedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookFailedBrush)); - ProcessQueueBookCompletedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookCompletedBrush)); - ProcessQueueBookCancelledBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookCancelledBrush)); - SeriesEntryGridBackgroundBrush = AvaloniaUtils.GetBrushFromResources(nameof(SeriesEntryGridBackgroundBrush)); - ProcessQueueBookDefaultBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookDefaultBrush)); - } - } + LoadStyles(); + var mainWindow = new MainWindow(); + desktop.MainWindow = MainWindow = mainWindow; + mainWindow.OnLibraryLoaded(LibraryTask.GetAwaiter().GetResult()); + mainWindow.RestoreSizeAndLocation(Configuration.Instance); + mainWindow.Show(); + } + + private static void LoadStyles() + { + ProcessQueueBookFailedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookFailedBrush)); + ProcessQueueBookCompletedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookCompletedBrush)); + ProcessQueueBookCancelledBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookCancelledBrush)); + SeriesEntryGridBackgroundBrush = AvaloniaUtils.GetBrushFromResources(nameof(SeriesEntryGridBackgroundBrush)); + ProcessQueueBookDefaultBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookDefaultBrush)); + } + + private static void RegisterTypes() + { + ServiceLocator.RegisterTransient(); + ServiceLocator.RegisterSingleton(typeof(SidebarViewModel)); + ServiceLocator.RegisterSingleton(typeof(PlayerViewModel)); + ServiceLocator.RegisterSingleton(typeof(ProcessQueueViewModel)); + + // Register VMs here only. + foreach (var type in Assembly.GetExecutingAssembly().GetExportedTypes()) + { + if (type.IsSubclassOf(typeof(ViewModelBase)) && !type.IsAbstract) + ServiceLocator.RegisterTransient(type); + } + + // Add more types as needed here. + } + } } diff --git a/Source/LibationAvalonia/Dialogs/LiberatedStatusBatchManualDialog.axaml b/Source/LibationAvalonia/Dialogs/LiberatedStatusBatchManualDialog.axaml index 984e39d6f..9a6e373b4 100644 --- a/Source/LibationAvalonia/Dialogs/LiberatedStatusBatchManualDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/LiberatedStatusBatchManualDialog.axaml @@ -35,7 +35,7 @@ MinHeight="25" Height="25" VerticalAlignment="Center" - SelectedItem="{Binding SelectedItem, Mode=TwoWay}" + SelectedItem="{Binding SelectedBook, Mode=TwoWay}" ItemsSource="{Binding BookStatuses}"> diff --git a/Source/LibationAvalonia/LibationAvalonia.csproj b/Source/LibationAvalonia/LibationAvalonia.csproj index 91c8de6fc..02e96762e 100644 --- a/Source/LibationAvalonia/LibationAvalonia.csproj +++ b/Source/LibationAvalonia/LibationAvalonia.csproj @@ -57,6 +57,9 @@ LiberateStatusButton.axaml + + SidebarControl.axaml + MainVM.cs @@ -79,6 +82,7 @@ + @@ -87,6 +91,11 @@ + + + + + diff --git a/Source/LibationAvalonia/ViewLocator.cs b/Source/LibationAvalonia/ViewLocator.cs index c604e0830..3459cb45c 100644 --- a/Source/LibationAvalonia/ViewLocator.cs +++ b/Source/LibationAvalonia/ViewLocator.cs @@ -18,7 +18,7 @@ public Control Build(object data) } else { - return new TextBlock { Text = "Not Found: " + name }; + return new TextBlock { Text = $"View Not Found: {name}" }; } } diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs index f54870222..3d05a7c43 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs @@ -19,7 +19,7 @@ public void BackupAllBooks() Serilog.Log.Logger.Information("Begin backing up all library books"); - ProcessQueue.AddDownloadDecrypt( + Sidebar.ProcessQueue.AddDownloadDecrypt( DbContexts .GetLibrary_Flat_NoTracking() .UnLiberated() @@ -34,7 +34,7 @@ public void BackupAllBooks() public void BackupAllPdfs() { setQueueCollapseState(false); - ProcessQueue.AddDownloadPdf(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)); + Sidebar.ProcessQueue.AddDownloadPdf(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)); } public async Task ConvertAllToMp3Async() @@ -50,7 +50,7 @@ public async Task ConvertAllToMp3Async() if (result == DialogResult.Yes) { setQueueCollapseState(false); - ProcessQueue.AddConvertMp3(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated && lb.Book.ContentType is ContentType.Product)); + Sidebar.ProcessQueue.AddConvertMp3(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated && lb.Book.ContentType is ContentType.Product)); } //Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing. } diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs b/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs index 9c30a7cc0..f29daaf57 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs @@ -40,13 +40,13 @@ public async void LiberateClicked(LibraryBook libraryBook) { Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", libraryBook); setQueueCollapseState(false); - ProcessQueue.AddDownloadDecrypt(libraryBook); + Sidebar.ProcessQueue.AddDownloadDecrypt(libraryBook); } else if (libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) { Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", libraryBook); setQueueCollapseState(false); - ProcessQueue.AddDownloadPdf(libraryBook); + Sidebar.ProcessQueue.AddDownloadPdf(libraryBook); } else if (libraryBook.Book.Audio_Exists()) { @@ -56,7 +56,7 @@ public async void LiberateClicked(LibraryBook libraryBook) if (!Go.To.File(filePath?.ShortPathName)) { var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; - await MessageBox.Show($"File not found" + suffix); + await MessageBox.Show($"File not found: {suffix}"); } } } @@ -74,7 +74,7 @@ public void LiberateSeriesClicked(ISeriesEntry series) Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook); - ProcessQueue.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated()); + Sidebar.ProcessQueue.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated()); } catch (Exception ex) { @@ -90,7 +90,7 @@ public void ConvertToMp3Clicked(LibraryBook libraryBook) { Serilog.Log.Logger.Information("Begin convert to mp3 {libraryBook}", libraryBook); setQueueCollapseState(false); - ProcessQueue.AddConvertMp3(libraryBook); + Sidebar.ProcessQueue.AddConvertMp3(libraryBook); } } catch (Exception ex) diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs index 2dd4829ca..8706ff698 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs @@ -6,78 +6,76 @@ using System.Collections.Generic; using System.Linq; -namespace LibationAvalonia.ViewModels -{ - partial class MainVM - { - private readonly InterruptableTimer autoScanTimer = new InterruptableTimer(TimeSpan.FromMinutes(5)); +namespace LibationAvalonia.ViewModels; - private void Configure_ScanAuto() - { - // subscribe as async/non-blocking. I'd actually rather prefer blocking but real-world testing found that caused a deadlock in the AudibleAPI - autoScanTimer.Elapsed += async (_, __) => - { - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var accounts = persister.AccountsSettings - .GetAll() - .Where(a => a.LibraryScan) - .ToArray(); +partial class MainVM +{ + private readonly InterruptableTimer autoScanTimer = new InterruptableTimer(TimeSpan.FromMinutes(5)); - // in autoScan, new books SHALL NOT show dialog - try - { - await LibraryCommands.ImportAccountAsync(LibationAvalonia.Dialogs.Login.AvaloniaLoginChoiceEager.ApiExtendedFunc, accounts); - } - catch (OperationCanceledException) - { - Serilog.Log.Information("Audible login attempt cancelled by user"); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error invoking auto-scan"); - } - }; + private void Configure_ScanAuto() + { + // subscribe as async/non-blocking. I'd actually rather prefer blocking but real-world testing found that caused a deadlock in the AudibleAPI + autoScanTimer.Elapsed += async (_, __) => + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var accounts = persister.AccountsSettings + .GetAll() + .Where(a => a.LibraryScan) + .ToArray(); - // if enabled: begin on load - MainWindow.Loaded += startAutoScan; + // in autoScan, new books SHALL NOT show dialog + try + { + await LibraryCommands.ImportAccountAsync(LibationAvalonia.Dialogs.Login.AvaloniaLoginChoiceEager.ApiExtendedFunc, accounts); + } + catch (OperationCanceledException) + { + Serilog.Log.Information("Audible login attempt cancelled by user"); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error invoking auto-scan"); + } + }; - // if new 'default' account is added, run autoscan - AccountsSettingsPersister.Saving += accountsPreSave; - AccountsSettingsPersister.Saved += accountsPostSave; + // if enabled: begin on load + MainWindow.Loaded += startAutoScan; - // when autoscan setting is changed, update menu checkbox and run autoscan - Configuration.Instance.PropertyChanged += startAutoScan; - } + // if new 'default' account is added, run autoscan + AccountsSettingsPersister.Saving += accountsPreSave; + AccountsSettingsPersister.Saved += accountsPostSave; + // when autoscan setting is changed, update menu checkbox and run autoscan + Configuration.Instance.PropertyChanged += startAutoScan; + } - private List<(string AccountId, string LocaleName)> preSaveDefaultAccounts; - private List<(string AccountId, string LocaleName)> getDefaultAccounts() - { - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - return persister.AccountsSettings - .GetAll() - .Where(a => a.LibraryScan) - .Select(a => (a.AccountId, a.Locale.Name)) - .ToList(); - } + private List<(string AccountId, string LocaleName)> preSaveDefaultAccounts; + private List<(string AccountId, string LocaleName)> getDefaultAccounts() + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + return persister.AccountsSettings + .GetAll() + .Where(a => a.LibraryScan) + .Select(a => (a.AccountId, a.Locale.Name)) + .ToList(); + } - private void accountsPreSave(object sender = null, EventArgs e = null) - => preSaveDefaultAccounts = getDefaultAccounts(); + private void accountsPreSave(object sender = null, EventArgs e = null) + => preSaveDefaultAccounts = getDefaultAccounts(); - private void accountsPostSave(object sender = null, EventArgs e = null) - { - if (getDefaultAccounts().Except(preSaveDefaultAccounts).Any()) - startAutoScan(); - } + private void accountsPostSave(object sender = null, EventArgs e = null) + { + if (getDefaultAccounts().Except(preSaveDefaultAccounts).Any()) + startAutoScan(); + } - [PropertyChangeFilter(nameof(Configuration.AutoScan))] - private void startAutoScan(object sender = null, EventArgs e = null) - { - AutoScanChecked = Configuration.Instance.AutoScan; - if (AutoScanChecked) - autoScanTimer.PerformNow(); - else - autoScanTimer.Stop(); - } - } -} + [PropertyChangeFilter(nameof(Configuration.AutoScan))] + private void startAutoScan(object sender = null, EventArgs e = null) + { + AutoScanChecked = Configuration.Instance.AutoScan; + if (AutoScanChecked) + autoScanTimer.PerformNow(); + else + autoScanTimer.Stop(); + } +} \ No newline at end of file diff --git a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs index db6fe17bc..166c22cdd 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs @@ -74,7 +74,7 @@ public void LiberateVisible() Serilog.Log.Logger.Information("Begin backing up visible library books"); - ProcessQueue.AddDownloadDecrypt( + Sidebar.ProcessQueue.AddDownloadDecrypt( ProductsDisplay .GetVisibleBookEntries() .UnLiberated() diff --git a/Source/LibationAvalonia/ViewModels/MainVM.cs b/Source/LibationAvalonia/ViewModels/MainVM.cs index 86c69d9d9..69b367c82 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.cs @@ -1,25 +1,27 @@ using ApplicationServices; using LibationAvalonia.Views; using LibationFileManager; +using LibationUiBase; using ReactiveUI; +using Splat; namespace LibationAvalonia.ViewModels { - public partial class MainVM : ViewModelBase + public partial class MainVM : ViewModelBase { - public ProcessQueueViewModel ProcessQueue { get; } = new ProcessQueueViewModel(); - public ProductsDisplayViewModel ProductsDisplay { get; } = new ProductsDisplayViewModel(); + public SidebarViewModel Sidebar { get; } = ServiceLocator.Get(); + public ProductsDisplayViewModel ProductsDisplay { get; } = ServiceLocator.Get(); private double? _downloadProgress = null; public double? DownloadProgress { get => _downloadProgress; set => this.RaiseAndSetIfChanged(ref _downloadProgress, value); } + public readonly MainWindow MainWindow; - private readonly MainWindow MainWindow; - public MainVM(MainWindow mainWindow) - { - MainWindow = mainWindow; + public MainVM(MainWindow mainWindow) + { + MainWindow = mainWindow; - ProductsDisplay.RemovableCountChanged += (_, removeCount) => RemoveBooksButtonText = removeCount == 1 ? "Remove 1 Book from Libation" : $"Remove {removeCount} Books from Libation"; + ProductsDisplay.RemovableCountChanged += (_, removeCount) => RemoveBooksButtonText = removeCount == 1 ? "Remove 1 Book from Libation" : $"Remove {removeCount} Books from Libation"; LibraryCommands.LibrarySizeChanged += async (_, _) => await ProductsDisplay.UpdateGridAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); Configure_NonUI(); diff --git a/Source/LibationAvalonia/ViewModels/SidebarViewModel.cs b/Source/LibationAvalonia/ViewModels/SidebarViewModel.cs new file mode 100644 index 000000000..7a86c2330 --- /dev/null +++ b/Source/LibationAvalonia/ViewModels/SidebarViewModel.cs @@ -0,0 +1,11 @@ +using LibationUiBase; +using LibationUiBase.ViewModels.Player; + +namespace LibationAvalonia.ViewModels +{ + public class SidebarViewModel + { + public PlayerViewModel Player { get; } = ServiceLocator.Get(); + public ProcessQueueViewModel ProcessQueue { get; } = ServiceLocator.Get(); + } +} diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow.axaml index 49f015992..cc2eea74a 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml +++ b/Source/LibationAvalonia/Views/MainWindow.axaml @@ -1,242 +1,417 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index 87823b173..2112f8e28 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Linq; +using LibationUiBase; namespace LibationAvalonia.Views { @@ -16,7 +17,9 @@ public partial class MainWindow : ReactiveWindow public event EventHandler> LibraryLoaded; public MainWindow() { - DataContext = new MainVM(this); + var vm = new MainVM(this); + DataContext = vm; + InitializeComponent(); Configure_Upgrade(); diff --git a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml deleted file mode 100644 index 03323d4e6..000000000 --- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - Process Queue - - - - - - - - - - - - - - - - - - - - - - - - Queue Log - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs deleted file mode 100644 index 0b7761d84..000000000 --- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs +++ /dev/null @@ -1,190 +0,0 @@ -using ApplicationServices; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Data.Converters; -using DataLayer; -using LibationAvalonia.ViewModels; -using LibationUiBase; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; - -namespace LibationAvalonia.Views -{ - public partial class ProcessQueueControl : UserControl - { - private TrackedQueue Queue => _viewModel.Queue; - private ProcessQueueViewModel _viewModel => DataContext as ProcessQueueViewModel; - - public ProcessQueueControl() - { - InitializeComponent(); - - ProcessBookControl.PositionButtonClicked += ProcessBookControl2_ButtonClicked; - ProcessBookControl.CancelButtonClicked += ProcessBookControl2_CancelButtonClicked; - - #region Design Mode Testing - if (Design.IsDesignMode) - { - var vm = new ProcessQueueViewModel(); - var Logger = LogMe.RegisterForm(vm); - DataContext = vm; - using var context = DbContexts.GetContext(); - List testList = new() - { - new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), Logger) - { - Result = ProcessBookResult.FailedAbort, - Status = ProcessBookStatus.Failed, - }, - new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"), Logger) - { - Result = ProcessBookResult.FailedSkip, - Status = ProcessBookStatus.Failed, - }, - new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"), Logger) - { - Result = ProcessBookResult.FailedRetry, - Status = ProcessBookStatus.Failed, - }, - new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"), Logger) - { - Result = ProcessBookResult.ValidationFail, - Status = ProcessBookStatus.Failed, - }, - new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"), Logger) - { - Result = ProcessBookResult.Cancelled, - Status = ProcessBookStatus.Cancelled, - }, - new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"), Logger) - { - Result = ProcessBookResult.Success, - Status = ProcessBookStatus.Completed, - }, - new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"), Logger) - { - Result = ProcessBookResult.None, - Status = ProcessBookStatus.Working, - }, - new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), Logger) - { - Result = ProcessBookResult.None, - Status = ProcessBookStatus.Queued, - }, - }; - - vm.Queue.Enqueue(testList); - vm.Queue.MoveNext(); - vm.Queue.MoveNext(); - vm.Queue.MoveNext(); - vm.Queue.MoveNext(); - vm.Queue.MoveNext(); - vm.Queue.MoveNext(); - vm.Queue.MoveNext(); - return; - } - #endregion - } - - public void NumericUpDown_KeyDown(object sender, Avalonia.Input.KeyEventArgs e) - { - if (e.Key == Avalonia.Input.Key.Enter && sender is Avalonia.Input.IInputElement input) input.Focus(); - } - - #region Control event handlers - - private async void ProcessBookControl2_CancelButtonClicked(ProcessBookViewModel item) - { - if (item is not null) - await item.CancelAsync(); - Queue.RemoveQueued(item); - } - - private void ProcessBookControl2_ButtonClicked(ProcessBookViewModel item, QueuePosition queueButton) - { - Queue.MoveQueuePosition(item, queueButton); - } - - public async void CancelAllBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - { - Queue.ClearQueue(); - if (Queue.Current is not null) - await Queue.Current.CancelAsync(); - } - - public void ClearFinishedBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - { - Queue.ClearCompleted(); - - if (!_viewModel.Running) - _viewModel.RunningTime = string.Empty; - } - - public void ClearLogBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - { - _viewModel.LogEntries.Clear(); - } - - private async void LogCopyBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - { - string logText = string.Join("\r\n", _viewModel.LogEntries.Select(r => $"{r.LogDate.ToShortDateString()} {r.LogDate.ToShortTimeString()}\t{r.LogMessage}")); - await App.MainWindow.Clipboard.SetTextAsync(logText); - } - - private async void cancelAllBtn_Click(object sender, EventArgs e) - { - Queue.ClearQueue(); - if (Queue.Current is not null) - await Queue.Current.CancelAsync(); - } - - private void btnClearFinished_Click(object sender, EventArgs e) - { - Queue.ClearCompleted(); - - if (!_viewModel.Running) - _viewModel.RunningTime = string.Empty; - } - - #endregion - } - - public class DecimalConverter : IValueConverter - { - public static readonly DecimalConverter Instance = new(); - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is string sourceText && targetType.IsAssignableTo(typeof(decimal?))) - { - if (sourceText == "∞") return 0; - - for (int i = sourceText.Length; i > 0; i--) - { - if (decimal.TryParse(sourceText[..i], out var val)) - return val; - } - - return 0; - } - return 0; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is decimal val) - { - return - val == 0 ? "∞" - : ( - val >= 10 ? ((long)val).ToString() - : val >= 1 ? val.ToString("F1") - : val.ToString("F2") - ) + " MB/s"; - } - return value.ToString(); - } - } -} diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml b/Source/LibationAvalonia/Views/ProductsDisplay.axaml index fd90a1c19..68f191fbe 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml @@ -1,260 +1,387 @@ - + - + - + - - - - - - - - - - - + + + + + + + - - - - - - + - - - - - - - + - - - - - - - + + + + + + - - - - - - - - - + + + + + + + - - - - - - - - - + + + + + + + - - - - - - - - - + + + + - - - + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 6f6948ab4..d2d0bc3bd 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -16,580 +16,599 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using AppScaffolding; +using LibationUiBase; +using System.Diagnostics; +using Dinah.Core.Logging; +using LibationUiBase.ViewModels.Player; namespace LibationAvalonia.Views { - public partial class ProductsDisplay : UserControl - { - public event EventHandler LiberateClicked; - public event EventHandler LiberateSeriesClicked; - public event EventHandler ConvertToMp3Clicked; - - private ProductsDisplayViewModel _viewModel => DataContext as ProductsDisplayViewModel; - ImageDisplayDialog imageDisplayDialog; - - public ProductsDisplay() - { - InitializeComponent(); - DataGridContextMenus.CellContextMenuStripNeeded += ProductsGrid_CellContextMenuStripNeeded; - - var cellSelector = Selectors.Is(null); - rowHeightStyle = new Style(_ => cellSelector); - rowHeightStyle.Setters.Add(rowHeightSetter); - - var tboxSelector = cellSelector.Descendant().Is(); - fontSizeStyle = new Style(_ => tboxSelector); - fontSizeStyle.Setters.Add(fontSizeSetter); - - var tboxH1Selector = cellSelector.Child().Is().Child().Is().Class("h1"); - fontSizeH1Style = new Style(_ => tboxH1Selector); - fontSizeH1Style.Setters.Add(fontSizeH1Setter); - - var tboxH2Selector = cellSelector.Child().Is().Child().Is().Class("h2"); - fontSizeH2Style = new Style(_ => tboxH2Selector); - fontSizeH2Style.Setters.Add(fontSizeH2Setter); - - Configuration.Instance.PropertyChanged += Configuration_GridScaleChanged; - Configuration.Instance.PropertyChanged += Configuration_FontChanged; - - if (Design.IsDesignMode) - { - using var context = DbContexts.GetContext(); - List sampleEntries; - try - { - sampleEntries = new() - { + public partial class ProductsDisplay : UserControl + { + public event EventHandler LiberateClicked; + public event EventHandler LiberateSeriesClicked; + public event EventHandler ConvertToMp3Clicked; + + private PlayerViewModel _playerViewModel = ServiceLocator.Get(); + private ProductsDisplayViewModel _viewModel => DataContext as ProductsDisplayViewModel; + ImageDisplayDialog imageDisplayDialog; + + public ProductsDisplay() + { + InitializeComponent(); + DataGridContextMenus.CellContextMenuStripNeeded += ProductsGrid_CellContextMenuStripNeeded; + + var cellSelector = Selectors.Is(null); + rowHeightStyle = new Style(_ => cellSelector); + rowHeightStyle.Setters.Add(rowHeightSetter); + + var tboxSelector = cellSelector.Descendant().Is(); + fontSizeStyle = new Style(_ => tboxSelector); + fontSizeStyle.Setters.Add(fontSizeSetter); + + var tboxH1Selector = cellSelector.Child().Is().Child().Is().Class("h1"); + fontSizeH1Style = new Style(_ => tboxH1Selector); + fontSizeH1Style.Setters.Add(fontSizeH1Setter); + + var tboxH2Selector = cellSelector.Child().Is().Child().Is().Class("h2"); + fontSizeH2Style = new Style(_ => tboxH2Selector); + fontSizeH2Style.Setters.Add(fontSizeH2Setter); + + Configuration.Instance.PropertyChanged += Configuration_GridScaleChanged; + Configuration.Instance.PropertyChanged += Configuration_FontChanged; + + if (Design.IsDesignMode) + { + using var context = DbContexts.GetContext(); + List sampleEntries; + try + { + sampleEntries = new() + { //context.GetLibraryBook_Flat_NoTracking("B00DCD0OXU"),try{ context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"), - context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"), - context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"), - context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"), - context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"), - context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"), - context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6") - }; - } - catch { sampleEntries = new(); } - - var pdvm = new ProductsDisplayViewModel(); - _ = pdvm.BindToGridAsync(sampleEntries); - DataContext = pdvm; - - setGridScale(1); - setFontScale(1); - return; - } - - setGridScale(Configuration.Instance.GridScaleFactor); - setFontScale(Configuration.Instance.GridFontScaleFactor); - Configure_ColumnCustomization(); - - foreach (var column in productsGrid.Columns) - { - column.CustomSortComparer = new RowComparer(column); - } - } - - private void RemoveColumn_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) - { - if (sender is DataGridColumn col && e.Property == DataGridColumn.IsVisibleProperty) - { - col.DisplayIndex = 0; - col.CanUserReorder = false; - } - } - - #region Scaling - - [PropertyChangeFilter(nameof(Configuration.GridScaleFactor))] - private void Configuration_GridScaleChanged(object sender, Dinah.Core.PropertyChangedEventArgsEx e) - { - setGridScale((float)e.NewValue); - } - - [PropertyChangeFilter(nameof(Configuration.GridFontScaleFactor))] - private void Configuration_FontChanged(object sender, Dinah.Core.PropertyChangedEventArgsEx e) - { - setFontScale((float)e.NewValue); - } - - private readonly Style rowHeightStyle; - private readonly Setter rowHeightSetter = new() { Property = DataGridCell.HeightProperty }; - - private readonly Style fontSizeStyle; - private readonly Setter fontSizeSetter = new() { Property = TextBlock.FontSizeProperty }; - - private readonly Style fontSizeH1Style; - private readonly Setter fontSizeH1Setter = new() { Property = TextBlock.FontSizeProperty }; - - private readonly Style fontSizeH2Style; - private readonly Setter fontSizeH2Setter = new() { Property = TextBlock.FontSizeProperty }; - - private void setFontScale(double scaleFactor) - { - const double TextBlockFontSize = 11; - const double H1FontSize = 14; - const double H2FontSize = 12; - - fontSizeSetter.Value = TextBlockFontSize * scaleFactor; - fontSizeH1Setter.Value = H1FontSize * scaleFactor; - fontSizeH2Setter.Value = H2FontSize * scaleFactor; - - productsGrid.Styles.Remove(fontSizeStyle); - productsGrid.Styles.Remove(fontSizeH1Style); - productsGrid.Styles.Remove(fontSizeH2Style); - productsGrid.Styles.Add(fontSizeStyle); - productsGrid.Styles.Add(fontSizeH1Style); - productsGrid.Styles.Add(fontSizeH2Style); - } - - private void setGridScale(double scaleFactor) - { - const float BaseRowHeight = 80; - const float BaseLiberateWidth = 75; - const float BaseCoverWidth = 80; - - foreach (var column in productsGrid.Columns) - { - switch (column.SortMemberPath) - { - case nameof(IGridEntry.Liberate): - column.Width = new DataGridLength(BaseLiberateWidth * scaleFactor); - break; - case nameof(IGridEntry.Cover): - column.Width = new DataGridLength(BaseCoverWidth * scaleFactor); - break; - } - } - rowHeightSetter.Value = BaseRowHeight * scaleFactor; - productsGrid.Styles.Remove(rowHeightStyle); - productsGrid.Styles.Add(rowHeightStyle); - } - - #endregion - - #region Cell Context Menu - - public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellContextMenuStripNeededEventArgs args) - { - var entry = args.GridEntry; - var ctx = new GridContextMenu(entry, '_'); - - if (args.Column.SortMemberPath is not "Liberate" and not "Cover") - { - args.ContextMenuItems.Add(new MenuItem - { - Header = ctx.CopyCellText, - Command = ReactiveCommand.CreateFromTask(() => App.MainWindow.Clipboard.SetTextAsync(args.CellClipboardContents)) - }); - args.ContextMenuItems.Add(new Separator()); - } - - #region Liberate all Episodes - - if (entry.Liberate.IsSeries) - { - args.ContextMenuItems.Add(new MenuItem() - { - Header = ctx.LiberateEpisodesText, - IsEnabled = ctx.LiberateEpisodesEnabled, - Command = ReactiveCommand.Create(() => LiberateSeriesClicked?.Invoke(this, (ISeriesEntry)entry)) - }); - } - - #endregion - #region Set Download status to Downloaded - - args.ContextMenuItems.Add(new MenuItem() - { - Header = ctx.SetDownloadedText, - IsEnabled = ctx.SetDownloadedEnabled, - Command = ReactiveCommand.Create(ctx.SetDownloaded) - }); - - #endregion - #region Set Download status to Not Downloaded - - args.ContextMenuItems.Add(new MenuItem() - { - Header = ctx.SetNotDownloadedText, - IsEnabled = ctx.SetNotDownloadedEnabled, - Command = ReactiveCommand.Create(ctx.SetNotDownloaded) - }); - - #endregion - #region Remove from library - - args.ContextMenuItems.Add(new MenuItem - { - Header = ctx.RemoveText, - Command = ReactiveCommand.CreateFromTask(ctx.RemoveAsync) - }); - - #endregion - - if (!entry.Liberate.IsSeries) - { - #region Locate file - - args.ContextMenuItems.Add(new MenuItem - { - Header = ctx.LocateFileText, - Command = ReactiveCommand.CreateFromTask(async () => - { - try - { - var window = this.GetParentWindow(); - - var openFileDialogOptions = new FilePickerOpenOptions - { - Title = ctx.LocateFileDialogTitle, - AllowMultiple = false, - SuggestedStartLocation = await window.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix), - FileTypeFilter = new FilePickerFileType[] - { - new("All files (*.*)") { Patterns = new[] { "*" } }, - } - }; - - var selectedFiles = await window.StorageProvider.OpenFilePickerAsync(openFileDialogOptions); - var selectedFile = selectedFiles.SingleOrDefault()?.TryGetLocalPath(); - - if (selectedFile is not null) - FilePathCache.Insert(entry.AudibleProductId, selectedFile); - } - catch (Exception ex) - { - await MessageBox.ShowAdminAlert(null, ctx.LocateFileErrorMessage, ctx.LocateFileErrorMessage, ex); - } - }) - }); - - #endregion - #region Convert to Mp3 - args.ContextMenuItems.Add(new MenuItem - { - Header = ctx.ConvertToMp3Text, - IsEnabled = ctx.ConvertToMp3Enabled, - Command = ReactiveCommand.Create(() => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook)) - }); - - #endregion - } - - #region Force Re-Download - if (!entry.Liberate.IsSeries) - { - args.ContextMenuItems.Add(new MenuItem() - { - Header = ctx.ReDownloadText, - IsEnabled = ctx.ReDownloadEnabled, - Command = ReactiveCommand.Create(() => - { - //No need to persist this change. It only needs to last long for the file to start downloading - entry.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated; - LiberateClicked?.Invoke(this, entry.LibraryBook); - }) - }); - } - #endregion - - args.ContextMenuItems.Add(new Separator()); - - #region Edit Templates - async Task editTemplate(LibraryBook libraryBook, string existingTemplate, Action setNewTemplate) - where T : Templates, LibationFileManager.ITemplate, new() - { - var template = ctx.CreateTemplateEditor(libraryBook, existingTemplate); - var form = new EditTemplateDialog(template); - if (await form.ShowDialog(this.GetParentWindow()) == DialogResult.OK) - { - setNewTemplate(template.EditingTemplate.TemplateText); - } - } - - if (!entry.Liberate.IsSeries) - { - args.ContextMenuItems.Add(new MenuItem - { - Header = ctx.EditTemplatesText, - ItemsSource = new[] - { - new MenuItem - { - Header = ctx.FolderTemplateText, - Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry.LibraryBook, Configuration.Instance.FolderTemplate, t => Configuration.Instance.FolderTemplate = t)) - }, - new MenuItem - { - Header = ctx.FileTemplateText, - Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry.LibraryBook, Configuration.Instance.FileTemplate, t => Configuration.Instance.FileTemplate = t)) - }, - new MenuItem - { - Header = ctx.MultipartTemplateText, - Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry.LibraryBook, Configuration.Instance.ChapterFileTemplate, t => Configuration.Instance.ChapterFileTemplate = t)) - } - } - }); - args.ContextMenuItems.Add(new Separator()); - } - - #endregion - - #region View Bookmarks/Clips - - if (!entry.Liberate.IsSeries) - { - args.ContextMenuItems.Add(new MenuItem - { - Header = ctx.ViewBookmarksText, - Command = ReactiveCommand.CreateFromTask(() => new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window)) - }); - } - - #endregion - #region View All Series - - if (entry.Book.SeriesLink.Any()) - { - args.ContextMenuItems.Add(new MenuItem - { - Header = ctx.ViewSeriesText, - Command = ReactiveCommand.Create(() => new SeriesViewDialog(entry.LibraryBook).Show()) - }); - } - - #endregion - } - - #endregion - - #region Column Customizations - - private void Configure_ColumnCustomization() - { - if (Design.IsDesignMode) return; - - productsGrid.ColumnDisplayIndexChanged += ProductsGrid_ColumnDisplayIndexChanged; - - var config = Configuration.Instance; - var gridColumnsVisibilities = config.GridColumnsVisibilities; - var displayIndices = config.GridColumnsDisplayIndices; - - var contextMenu = new ContextMenu(); - contextMenu.Closed += ContextMenu_MenuClosed; - contextMenu.Opening += ContextMenu_ContextMenuOpening; - List menuItems = new(); - contextMenu.ItemsSource = menuItems; - - menuItems.Add(new MenuItem { Header = "Show / Hide Columns" }); - menuItems.Add(new MenuItem { Header = "-" }); - - var HeaderCell_PI = typeof(DataGridColumn).GetProperty("HeaderCell", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - foreach (var column in productsGrid.Columns) - { - var itemName = column.SortMemberPath; - - if (itemName == nameof(IGridEntry.Remove)) - continue; - - menuItems.Add - ( - new MenuItem - { - Header = ((string)column.Header).Replace('\n', ' '), - Tag = column, - Icon = new CheckBox(), - } - ); - - var headercell = HeaderCell_PI.GetValue(column) as DataGridColumnHeader; - headercell.ContextMenu = contextMenu; - - column.IsVisible = gridColumnsVisibilities.GetValueOrDefault(itemName, true); - } - - //We must set DisplayIndex properties in ascending order - foreach (var itemName in displayIndices.OrderBy(i => i.Value).Select(i => i.Key)) - { - if (!productsGrid.Columns.Any(c => c.SortMemberPath == itemName)) - continue; - - var column = productsGrid.Columns - .Single(c => c.SortMemberPath == itemName); - - column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, productsGrid.Columns.IndexOf(column)); - } - } - - private void ContextMenu_ContextMenuOpening(object sender, System.ComponentModel.CancelEventArgs e) - { - var contextMenu = sender as ContextMenu; - foreach (var mi in contextMenu.Items.OfType()) - { - if (mi.Tag is DataGridColumn column) - { - var cbox = mi.Icon as CheckBox; - cbox.IsChecked = column.IsVisible; - } - } - } - - private void ContextMenu_MenuClosed(object sender, Avalonia.Interactivity.RoutedEventArgs e) - { - var contextMenu = sender as ContextMenu; - var config = Configuration.Instance; - var dictionary = config.GridColumnsVisibilities; - - foreach (var mi in contextMenu.Items.OfType()) - { - if (mi.Tag is DataGridColumn column) - { - var cbox = mi.Icon as CheckBox; - column.IsVisible = cbox.IsChecked == true; - dictionary[column.SortMemberPath] = cbox.IsChecked == true; - } - } - - //If all columns are hidden, register the context menu on the grid so users can unhide. - if (!productsGrid.Columns.Any(c => c.IsVisible)) - productsGrid.ContextMenu = contextMenu; - else - productsGrid.ContextMenu = null; - - config.GridColumnsVisibilities = dictionary; - } - - private void ProductsGrid_ColumnDisplayIndexChanged(object sender, DataGridColumnEventArgs e) - { - var config = Configuration.Instance; - - var dictionary = config.GridColumnsDisplayIndices; - dictionary[e.Column.SortMemberPath] = e.Column.DisplayIndex; - config.GridColumnsDisplayIndices = dictionary; - } - - #endregion - - #region Button Click Handlers - - public async void LiberateButton_Click(object sender, EventArgs e) - { - var button = sender as LiberateStatusButton; - - if (button.DataContext is ISeriesEntry sEntry) - { - await _viewModel.ToggleSeriesExpanded(sEntry); - - //Expanding and collapsing reset the list, which will cause focus to shift - //to the topright cell. Reset focus onto the clicked button's cell. - button.Focus(); - } - else if (button.DataContext is ILibraryBookEntry lbEntry) - { - LiberateClicked?.Invoke(this, lbEntry.LibraryBook); - } - } - - public void CloseImageDisplay() - { - if (imageDisplayDialog is not null && imageDisplayDialog.IsVisible) - imageDisplayDialog.Close(); - } - - public void Version_DoubleClick(object sender, Avalonia.Input.TappedEventArgs args) - { - if (sender is Control panel && panel.DataContext is ILibraryBookEntry lbe && lbe.LastDownload.IsValid) - lbe.LastDownload.OpenReleaseUrl(); - } - - public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) - { - if (sender is not Image tblock || tblock.DataContext is not IGridEntry gEntry) - return; - - if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible) - { - imageDisplayDialog = new ImageDisplayDialog(); - } - - var picDef = new PictureDefinition(gEntry.LibraryBook.Book.PictureLarge ?? gEntry.LibraryBook.Book.PictureId, PictureSize.Native); - - void PictureCached(object sender, PictureCachedEventArgs e) - { - if (e.Definition.PictureId == picDef.PictureId) - imageDisplayDialog.SetCoverBytes(e.Picture); - - PictureStorage.PictureCached -= PictureCached; - } - - PictureStorage.PictureCached += PictureCached; - (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); - - - var windowTitle = $"{gEntry.Title} - Cover"; - - - imageDisplayDialog.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(gEntry.LibraryBook); - imageDisplayDialog.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(gEntry.LibraryBook, ".jpg")); - imageDisplayDialog.Title = windowTitle; - imageDisplayDialog.SetCoverBytes(initialImageBts); - - if (!isDefault) - PictureStorage.PictureCached -= PictureCached; - - if (imageDisplayDialog.IsVisible) - imageDisplayDialog.Activate(); - else - imageDisplayDialog.Show(); - } - - public void Description_Click(object sender, Avalonia.Input.TappedEventArgs args) - { - if (sender is Control tblock && tblock.DataContext is IGridEntry gEntry) - { - var pt = tblock.PointToScreen(tblock.Bounds.TopRight); - var displayWindow = new DescriptionDisplayDialog - { - SpawnLocation = new Point(pt.X, pt.Y), - DescriptionText = gEntry.Description, - }; - - void CloseWindow(object o, DataGridRowEventArgs e) - { - displayWindow.Close(); - } - productsGrid.LoadingRow += CloseWindow; - displayWindow.Closing += (_, _) => - { - productsGrid.LoadingRow -= CloseWindow; - }; - - displayWindow.Show(); - } - } - - BookDetailsDialog bookDetailsForm; - - public void OnTagsButtonClick(object sender, Avalonia.Interactivity.RoutedEventArgs args) - { - var button = args.Source as Button; - - if (button.DataContext is ILibraryBookEntry lbEntry && VisualRoot is Window window) - { - if (bookDetailsForm is null || !bookDetailsForm.IsVisible) - { - bookDetailsForm = new BookDetailsDialog(lbEntry.LibraryBook); - bookDetailsForm.Show(window); - } - else - bookDetailsForm.LibraryBook = lbEntry.LibraryBook; - } - } - - #endregion - } + context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"), + context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"), + context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"), + context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"), + context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"), + context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6") + }; + } + catch { sampleEntries = new(); } + + var pdvm = new ProductsDisplayViewModel(); + _ = pdvm.BindToGridAsync(sampleEntries); + DataContext = pdvm; + + setGridScale(1); + setFontScale(1); + return; + } + + setGridScale(Configuration.Instance.GridScaleFactor); + setFontScale(Configuration.Instance.GridFontScaleFactor); + Configure_ColumnCustomization(); + + foreach (var column in productsGrid.Columns) + { + column.CustomSortComparer = new RowComparer(column); + } + } + + private void RemoveColumn_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (sender is DataGridColumn col && e.Property == DataGridColumn.IsVisibleProperty) + { + col.DisplayIndex = 0; + col.CanUserReorder = false; + } + } + + #region Scaling + + [PropertyChangeFilter(nameof(Configuration.GridScaleFactor))] + private void Configuration_GridScaleChanged(object sender, Dinah.Core.PropertyChangedEventArgsEx e) + { + setGridScale((float)e.NewValue); + } + + [PropertyChangeFilter(nameof(Configuration.GridFontScaleFactor))] + private void Configuration_FontChanged(object sender, Dinah.Core.PropertyChangedEventArgsEx e) + { + setFontScale((float)e.NewValue); + } + + private readonly Style rowHeightStyle; + private readonly Setter rowHeightSetter = new() { Property = DataGridCell.HeightProperty }; + + private readonly Style fontSizeStyle; + private readonly Setter fontSizeSetter = new() { Property = TextBlock.FontSizeProperty }; + + private readonly Style fontSizeH1Style; + private readonly Setter fontSizeH1Setter = new() { Property = TextBlock.FontSizeProperty }; + + private readonly Style fontSizeH2Style; + private readonly Setter fontSizeH2Setter = new() { Property = TextBlock.FontSizeProperty }; + + private void setFontScale(double scaleFactor) + { + const double TextBlockFontSize = 11; + const double H1FontSize = 14; + const double H2FontSize = 12; + + fontSizeSetter.Value = TextBlockFontSize * scaleFactor; + fontSizeH1Setter.Value = H1FontSize * scaleFactor; + fontSizeH2Setter.Value = H2FontSize * scaleFactor; + + productsGrid.Styles.Remove(fontSizeStyle); + productsGrid.Styles.Remove(fontSizeH1Style); + productsGrid.Styles.Remove(fontSizeH2Style); + productsGrid.Styles.Add(fontSizeStyle); + productsGrid.Styles.Add(fontSizeH1Style); + productsGrid.Styles.Add(fontSizeH2Style); + } + + private void setGridScale(double scaleFactor) + { + const float BaseRowHeight = 80; + const float BaseLiberateWidth = 75; + const float BaseCoverWidth = 80; + + foreach (var column in productsGrid.Columns) + { + switch (column.SortMemberPath) + { + case nameof(IGridEntry.Liberate): + column.Width = new DataGridLength(BaseLiberateWidth * scaleFactor); + break; + case nameof(IGridEntry.Cover): + column.Width = new DataGridLength(BaseCoverWidth * scaleFactor); + break; + } + } + rowHeightSetter.Value = BaseRowHeight * scaleFactor; + productsGrid.Styles.Remove(rowHeightStyle); + productsGrid.Styles.Add(rowHeightStyle); + } + + #endregion + + #region Cell Context Menu + + public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellContextMenuStripNeededEventArgs args) + { + var entry = args.GridEntry; + var ctx = new GridContextMenu(entry, '_'); + + if (args.Column.SortMemberPath is not "Liberate" and not "Cover") + { + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.CopyCellText, + Command = ReactiveCommand.CreateFromTask(() => App.MainWindow.Clipboard.SetTextAsync(args.CellClipboardContents)) + }); + args.ContextMenuItems.Add(new Separator()); + } + + #region Liberate all Episodes + + if (entry.Liberate.IsSeries) + { + args.ContextMenuItems.Add(new MenuItem() + { + Header = ctx.LiberateEpisodesText, + IsEnabled = ctx.LiberateEpisodesEnabled, + Command = ReactiveCommand.Create(() => LiberateSeriesClicked?.Invoke(this, (ISeriesEntry)entry)) + }); + } + + #endregion + #region Set Download status to Downloaded + + args.ContextMenuItems.Add(new MenuItem() + { + Header = ctx.SetDownloadedText, + IsEnabled = ctx.SetDownloadedEnabled, + Command = ReactiveCommand.Create(ctx.SetDownloaded) + }); + + #endregion + #region Set Download status to Not Downloaded + + args.ContextMenuItems.Add(new MenuItem() + { + Header = ctx.SetNotDownloadedText, + IsEnabled = ctx.SetNotDownloadedEnabled, + Command = ReactiveCommand.Create(ctx.SetNotDownloaded) + }); + + #endregion + #region Remove from library + + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.RemoveText, + Command = ReactiveCommand.CreateFromTask(ctx.RemoveAsync) + }); + + #endregion + + if (!entry.Liberate.IsSeries) + { + #region Locate file + + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.LocateFileText, + Command = ReactiveCommand.CreateFromTask(async () => + { + try + { + var window = this.GetParentWindow(); + + var openFileDialogOptions = new FilePickerOpenOptions + { + Title = ctx.LocateFileDialogTitle, + AllowMultiple = false, + SuggestedStartLocation = await window.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix), + FileTypeFilter = new FilePickerFileType[] + { + new("All files (*.*)") { Patterns = new[] { "*" } }, + } + }; + + var selectedFiles = await window.StorageProvider.OpenFilePickerAsync(openFileDialogOptions); + var selectedFile = selectedFiles.SingleOrDefault()?.TryGetLocalPath(); + + if (selectedFile is not null) + FilePathCache.Insert(entry.AudibleProductId, selectedFile); + } + catch (Exception ex) + { + await MessageBox.ShowAdminAlert(null, ctx.LocateFileErrorMessage, ctx.LocateFileErrorMessage, ex); + } + }) + }); + + #endregion + #region Convert to Mp3 + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.ConvertToMp3Text, + IsEnabled = ctx.ConvertToMp3Enabled, + Command = ReactiveCommand.Create(() => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook)) + }); + + #endregion + } + + #region Force Re-Download + if (!entry.Liberate.IsSeries) + { + args.ContextMenuItems.Add(new MenuItem() + { + Header = ctx.ReDownloadText, + IsEnabled = ctx.ReDownloadEnabled, + Command = ReactiveCommand.Create(() => + { + //No need to persist this change. It only needs to last long for the file to start downloading + entry.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated; + LiberateClicked?.Invoke(this, entry.LibraryBook); + }) + }); + } + #endregion + + args.ContextMenuItems.Add(new Separator()); + + #region Edit Templates + async Task editTemplate(LibraryBook libraryBook, string existingTemplate, Action setNewTemplate) + where T : Templates, LibationFileManager.ITemplate, new() + { + var template = ctx.CreateTemplateEditor(libraryBook, existingTemplate); + var form = new EditTemplateDialog(template); + if (await form.ShowDialog(this.GetParentWindow()) == DialogResult.OK) + { + setNewTemplate(template.EditingTemplate.TemplateText); + } + } + + if (!entry.Liberate.IsSeries) + { + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.EditTemplatesText, + ItemsSource = new[] + { + new MenuItem + { + Header = ctx.FolderTemplateText, + Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry.LibraryBook, Configuration.Instance.FolderTemplate, t => Configuration.Instance.FolderTemplate = t)) + }, + new MenuItem + { + Header = ctx.FileTemplateText, + Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry.LibraryBook, Configuration.Instance.FileTemplate, t => Configuration.Instance.FileTemplate = t)) + }, + new MenuItem + { + Header = ctx.MultipartTemplateText, + Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry.LibraryBook, Configuration.Instance.ChapterFileTemplate, t => Configuration.Instance.ChapterFileTemplate = t)) + } + } + }); + args.ContextMenuItems.Add(new Separator()); + } + + #endregion + + #region View Bookmarks/Clips + + if (!entry.Liberate.IsSeries) + { + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.ViewBookmarksText, + Command = ReactiveCommand.CreateFromTask(() => new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window)) + }); + } + + #endregion + #region View All Series + + if (entry.Book.SeriesLink.Any()) + { + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.ViewSeriesText, + Command = ReactiveCommand.Create(() => new SeriesViewDialog(entry.LibraryBook).Show()) + }); + } + + #endregion + } + + #endregion + + #region Column Customizations + + private void Configure_ColumnCustomization() + { + if (Design.IsDesignMode) return; + + productsGrid.ColumnDisplayIndexChanged += ProductsGrid_ColumnDisplayIndexChanged; + + var config = Configuration.Instance; + var gridColumnsVisibilities = config.GridColumnsVisibilities; + var displayIndices = config.GridColumnsDisplayIndices; + + var contextMenu = new ContextMenu(); + contextMenu.Closed += ContextMenu_MenuClosed; + contextMenu.Opening += ContextMenu_ContextMenuOpening; + List menuItems = new(); + contextMenu.ItemsSource = menuItems; + + menuItems.Add(new MenuItem { Header = "Show / Hide Columns" }); + menuItems.Add(new MenuItem { Header = "-" }); + + var HeaderCell_PI = typeof(DataGridColumn).GetProperty("HeaderCell", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + foreach (var column in productsGrid.Columns) + { + var itemName = column.SortMemberPath; + + if (itemName == nameof(IGridEntry.Remove) || itemName == null) + continue; + + menuItems.Add + ( + new MenuItem + { + Header = ((string)column.Header)?.Replace('\n', ' '), + Tag = column, + Icon = new CheckBox(), + } + ); + + var headercell = HeaderCell_PI.GetValue(column) as DataGridColumnHeader; + headercell.ContextMenu = contextMenu; + + column.IsVisible = itemName != null ? gridColumnsVisibilities.GetValueOrDefault(itemName, true) : true; + } + + //We must set DisplayIndex properties in ascending order + foreach (var itemName in displayIndices.OrderBy(i => i.Value).Select(i => i.Key)) + { + if (!productsGrid.Columns.Any(c => c.SortMemberPath == itemName)) + continue; + + var column = productsGrid.Columns + .Single(c => c.SortMemberPath == itemName); + + column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, productsGrid.Columns.IndexOf(column)); + } + } + + private void ContextMenu_ContextMenuOpening(object sender, System.ComponentModel.CancelEventArgs e) + { + var contextMenu = sender as ContextMenu; + foreach (var mi in contextMenu.Items.OfType()) + { + if (mi.Tag is DataGridColumn column) + { + var cbox = mi.Icon as CheckBox; + cbox.IsChecked = column.IsVisible; + } + } + } + + private void ContextMenu_MenuClosed(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + var contextMenu = sender as ContextMenu; + var config = Configuration.Instance; + var dictionary = config.GridColumnsVisibilities; + + foreach (var mi in contextMenu.Items.OfType()) + { + if (mi.Tag is DataGridColumn column) + { + var cbox = mi.Icon as CheckBox; + column.IsVisible = cbox.IsChecked == true; + dictionary[column.SortMemberPath] = cbox.IsChecked == true; + } + } + + //If all columns are hidden, register the context menu on the grid so users can unhide. + if (!productsGrid.Columns.Any(c => c.IsVisible)) + productsGrid.ContextMenu = contextMenu; + else + productsGrid.ContextMenu = null; + + config.GridColumnsVisibilities = dictionary; + } + + private void ProductsGrid_ColumnDisplayIndexChanged(object sender, DataGridColumnEventArgs e) + { + var config = Configuration.Instance; + + var dictionary = config.GridColumnsDisplayIndices; + dictionary[e.Column.SortMemberPath] = e.Column.DisplayIndex; + config.GridColumnsDisplayIndices = dictionary; + } + + #endregion + + #region Button Click Handlers + + public async void LiberateButton_Click(object sender, EventArgs e) + { + var button = sender as LiberateStatusButton; + + if (button.DataContext is ISeriesEntry sEntry) + { + await _viewModel.ToggleSeriesExpanded(sEntry); + + //Expanding and collapsing reset the list, which will cause focus to shift + //to the topright cell. Reset focus onto the clicked button's cell. + button.Focus(); + } + else if (button.DataContext is ILibraryBookEntry lbEntry) + { + LiberateClicked?.Invoke(this, lbEntry.LibraryBook); + } + } + + public void CloseImageDisplay() + { + if (imageDisplayDialog is not null && imageDisplayDialog.IsVisible) + imageDisplayDialog.Close(); + } + + public void Version_DoubleClick(object sender, Avalonia.Input.TappedEventArgs args) + { + if (sender is Control panel && panel.DataContext is ILibraryBookEntry lbe && lbe.LastDownload.IsValid) + lbe.LastDownload.OpenReleaseUrl(); + } + + public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) + { + if (sender is not Image tblock || tblock.DataContext is not IGridEntry gEntry) + return; + + if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible) + { + imageDisplayDialog = new ImageDisplayDialog(); + } + + var picDef = new PictureDefinition(gEntry.LibraryBook.Book.PictureLarge ?? gEntry.LibraryBook.Book.PictureId, PictureSize.Native); + + void PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == picDef.PictureId) + imageDisplayDialog.SetCoverBytes(e.Picture); + + PictureStorage.PictureCached -= PictureCached; + } + + PictureStorage.PictureCached += PictureCached; + (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); + + + var windowTitle = $"{gEntry.Title} - Cover"; + + + imageDisplayDialog.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(gEntry.LibraryBook); + imageDisplayDialog.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(gEntry.LibraryBook, ".jpg")); + imageDisplayDialog.Title = windowTitle; + imageDisplayDialog.SetCoverBytes(initialImageBts); + + if (!isDefault) + PictureStorage.PictureCached -= PictureCached; + + if (imageDisplayDialog.IsVisible) + imageDisplayDialog.Activate(); + else + imageDisplayDialog.Show(); + } + + public void Description_Click(object sender, Avalonia.Input.TappedEventArgs args) + { + if (sender is Control tblock && tblock.DataContext is IGridEntry gEntry) + { + var pt = tblock.PointToScreen(tblock.Bounds.TopRight); + var displayWindow = new DescriptionDisplayDialog + { + SpawnLocation = new Point(pt.X, pt.Y), + DescriptionText = gEntry.Description, + }; + + void CloseWindow(object o, DataGridRowEventArgs e) + { + displayWindow.Close(); + } + productsGrid.LoadingRow += CloseWindow; + displayWindow.Closing += (_, _) => + { + productsGrid.LoadingRow -= CloseWindow; + }; + + displayWindow.Show(); + } + } + + BookDetailsDialog bookDetailsForm; + + public void OnTagsButtonClick(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var button = args.Source as Button; + + if (button.DataContext is ILibraryBookEntry lbEntry && VisualRoot is Window window) + { + if (bookDetailsForm is null || !bookDetailsForm.IsVisible) + { + bookDetailsForm = new BookDetailsDialog(lbEntry.LibraryBook); + bookDetailsForm.Show(window); + } + else + bookDetailsForm.LibraryBook = lbEntry.LibraryBook; + } + } + + private async void AddToPlaylistButton_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + var gridEntry = (IGridEntry)((Button)sender).DataContext; + if (gridEntry is ISeriesEntry se) + { + foreach (ILibraryBookEntry child in se.Children) + await _playerViewModel.AddToPlaylist(child); + } + else if (gridEntry is ILibraryBookEntry book) + await _playerViewModel.AddToPlaylist(book); + else + Serilog.Log.Logger.TryLogError($"Cannot add item type {gridEntry.GetType().Name} to playlist"); + } + #endregion + } } diff --git a/Source/LibationAvalonia/Views/SidebarControl.axaml b/Source/LibationAvalonia/Views/SidebarControl.axaml new file mode 100644 index 000000000..37fd52ea3 --- /dev/null +++ b/Source/LibationAvalonia/Views/SidebarControl.axaml @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Process Queue + + + + + + + + + + + + + + + + + + + + + + + + + Queue Log + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Playlist + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Views/SidebarControl.axaml.cs b/Source/LibationAvalonia/Views/SidebarControl.axaml.cs new file mode 100644 index 000000000..b3d9b5f85 --- /dev/null +++ b/Source/LibationAvalonia/Views/SidebarControl.axaml.cs @@ -0,0 +1,189 @@ +using ApplicationServices; +using Avalonia.Controls; +using Avalonia.Data.Converters; +using DataLayer; +using LibationAvalonia.ViewModels; +using LibationUiBase; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace LibationAvalonia.Views +{ + public partial class SidebarControl : UserControl + { + private TrackedQueue Queue => _processQueue.Queue; + private ProcessQueueViewModel _processQueue = ServiceLocator.Get(); + + public SidebarControl() + { + InitializeComponent(); + + ProcessBookControl.PositionButtonClicked += ProcessBookControl2_ButtonClicked; + ProcessBookControl.CancelButtonClicked += ProcessBookControl2_CancelButtonClicked; + + #region Design Mode Testing + if (Design.IsDesignMode) + { + var vm = ServiceLocator.Get(); + var Logger = LogMe.RegisterForm(vm); + DataContext = vm; + using var context = DbContexts.GetContext(); + List testList = new() + { + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), Logger) + { + Result = ProcessBookResult.FailedAbort, + Status = ProcessBookStatus.Failed, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"), Logger) + { + Result = ProcessBookResult.FailedSkip, + Status = ProcessBookStatus.Failed, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"), Logger) + { + Result = ProcessBookResult.FailedRetry, + Status = ProcessBookStatus.Failed, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"), Logger) + { + Result = ProcessBookResult.ValidationFail, + Status = ProcessBookStatus.Failed, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"), Logger) + { + Result = ProcessBookResult.Cancelled, + Status = ProcessBookStatus.Cancelled, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"), Logger) + { + Result = ProcessBookResult.Success, + Status = ProcessBookStatus.Completed, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"), Logger) + { + Result = ProcessBookResult.None, + Status = ProcessBookStatus.Working, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), Logger) + { + Result = ProcessBookResult.None, + Status = ProcessBookStatus.Queued, + }, + }; + + vm.Queue.Enqueue(testList); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + return; + } + #endregion + } + + public void NumericUpDown_KeyDown(object sender, Avalonia.Input.KeyEventArgs e) + { + if (e.Key == Avalonia.Input.Key.Enter && sender is Avalonia.Input.IInputElement input) input.Focus(); + } + + #region Control event handlers + + private async void ProcessBookControl2_CancelButtonClicked(ProcessBookViewModel item) + { + if (item is not null) + await item.CancelAsync(); + Queue.RemoveQueued(item); + } + + private void ProcessBookControl2_ButtonClicked(ProcessBookViewModel item, QueuePosition queueButton) + { + Queue.MoveQueuePosition(item, queueButton); + } + + public async void CancelAllBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + Queue.ClearQueue(); + if (Queue.Current is not null) + await Queue.Current.CancelAsync(); + } + + public void ClearFinishedBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + Queue.ClearCompleted(); + + if (!_processQueue.Running) + _processQueue.RunningTime = string.Empty; + } + + public void ClearLogBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + _processQueue.LogEntries.Clear(); + } + + private async void LogCopyBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + string logText = string.Join("\r\n", _processQueue.LogEntries.Select(r => $"{r.LogDate.ToShortDateString()} {r.LogDate.ToShortTimeString()}\t{r.LogMessage}")); + await App.MainWindow.Clipboard.SetTextAsync(logText); + } + + private async void cancelAllBtn_Click(object sender, EventArgs e) + { + Queue.ClearQueue(); + if (Queue.Current is not null) + await Queue.Current.CancelAsync(); + } + + private void btnClearFinished_Click(object sender, EventArgs e) + { + Queue.ClearCompleted(); + + if (!_processQueue.Running) + _processQueue.RunningTime = string.Empty; + } + + #endregion + } + + public class DecimalConverter : IValueConverter + { + public static readonly DecimalConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string sourceText && targetType.IsAssignableTo(typeof(decimal?))) + { + if (sourceText == "∞") return 0; + + for (int i = sourceText.Length; i > 0; i--) + { + if (decimal.TryParse(sourceText[..i], out var val)) + return val; + } + + return 0; + } + return 0; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is decimal val) + { + return + val == 0 ? "∞" + : ( + val >= 10 ? ((long)val).ToString() + : val >= 1 ? val.ToString("F1") + : val.ToString("F2") + ) + " MB/s"; + } + return value?.ToString(); + } + } +} diff --git a/Source/LibationAvalonia/WpfCanExecuteChanged.cs b/Source/LibationAvalonia/WpfCanExecuteChanged.cs new file mode 100644 index 000000000..116faf7c2 --- /dev/null +++ b/Source/LibationAvalonia/WpfCanExecuteChanged.cs @@ -0,0 +1,21 @@ +using Avalonia.Labs.Input; +using Avalonia.Threading; +using LibationUiBase.ViewModels; +using System; + +namespace LibationAvalonia +{ + public class WpfCanExecuteChanged : ICanExecuteChanged + { + public event EventHandler Event + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + public void Raise() + { + Dispatcher.UIThread.Post(CommandManager.InvalidateRequerySuggested); + } + } +} diff --git a/Source/LibationUiBase/BindingListExtensions.cs b/Source/LibationUiBase/BindingListExtensions.cs new file mode 100644 index 000000000..93c5a5942 --- /dev/null +++ b/Source/LibationUiBase/BindingListExtensions.cs @@ -0,0 +1,28 @@ +using AppScaffolding; +using System.Collections; + +namespace LibationUiBase; + +public static class BindingListExtensions +{ + public static void Swap(this IList list, int first, int second) + { + (list[first], list[second]) = (list[second], list[first]); + } + + public static void MoveDown(this IList list, object obj) + { + var idx = list.IndexOf(obj); + Ensure.IsValid(nameof(obj), idx != -1, "Object not in list"); + Ensure.IsValid(nameof(obj), idx < list.Count - 1, "Object already at end of list"); + list.Swap(idx, idx + 1); + } + + public static void MoveUp(this IList list, object obj) + { + var idx = list.IndexOf(obj); + Ensure.IsValid(nameof(obj), idx != -1, "Object not in list"); + Ensure.IsValid(nameof(obj), idx > 0, "Object already at beginning of list"); + list.Swap(idx, idx - 1); + } +} \ No newline at end of file diff --git a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs index f8ef11d42..7b01e4056 100644 --- a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs @@ -4,6 +4,7 @@ using Dinah.Core.Threading; using FileLiberator; using LibationFileManager; +using LibationUiBase.ViewModels.Player; using System; using System.Collections; using System.Collections.Generic; @@ -14,7 +15,7 @@ namespace LibationUiBase.GridView { - public enum RemoveStatus + public enum RemoveStatus { NotRemoved, Removed, @@ -24,7 +25,9 @@ public enum RemoveStatus /// The View Model base for the DataGridView public abstract class GridEntry : SynchronizeInvoker, IGridEntry where TStatus : IEntryStatus { - [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; + private PlayerViewModel _player = ServiceLocator.Get(); + + [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; [Browsable(false)] public LibraryBook LibraryBook { get; protected set; } [Browsable(false)] public float SeriesIndex { get; protected set; } [Browsable(false)] public abstract DateTime DateAdded { get; } @@ -123,7 +126,18 @@ public void UpdateLibraryBook(LibraryBook libraryBook) UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; } - protected abstract string GetBookTags(); + public virtual bool CanAddToPlaylistText => this is not ISeriesEntry; + public virtual string AddToPlaylistText + { + get + { + return this is ISeriesEntry book ? null : BookIsInPlaylist ? "Add to Playlist" : "Remove from Playlist"; + } + } + + public bool BookIsInPlaylist => this is ILibraryBookEntry book && _player.IsInPlaylist(book); + + protected abstract string GetBookTags(); protected virtual DateTime GetPurchaseDate() => LibraryBook.DateAdded; protected virtual int GetLengthInMinutes() => Book.LengthInMinutes; protected string GetPurchaseDateString() => GetPurchaseDate().ToString("d"); diff --git a/Source/LibationUiBase/GridView/IGridEntry.cs b/Source/LibationUiBase/GridView/IGridEntry.cs index ed3ebb9ac..9f810a750 100644 --- a/Source/LibationUiBase/GridView/IGridEntry.cs +++ b/Source/LibationUiBase/GridView/IGridEntry.cs @@ -5,7 +5,7 @@ namespace LibationUiBase.GridView { - public interface IGridEntry : IMemberComparable, INotifyPropertyChanged + public interface IGridEntry : IMemberComparable, INotifyPropertyChanged { EntryStatus Liberate { get; } float SeriesIndex { get; } @@ -30,5 +30,11 @@ public interface IGridEntry : IMemberComparable, INotifyPropertyChanged Rating MyRating { get; set; } string BookTags { get; } void UpdateLibraryBook(LibraryBook libraryBook); - } + + bool CanAddToPlaylistText { get; } + string AddToPlaylistText { get; } + bool BookIsInPlaylist { get; } + + //int? CurrentChapterNumber { get; } + } } diff --git a/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs b/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs index 5866ae409..52038e316 100644 --- a/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs @@ -68,6 +68,10 @@ public static async Task> GetAllProductsAsync(IEnumerable a).ToList(); } - protected override string GetBookTags() => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated); - } + public override string AddToPlaylistText => BookIsInPlaylist ? "Remove From Playlist" : "Add to Playlist"; + + protected override string GetBookTags() => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated); + + public override string ToString() => $"{AudibleProductId} - {Title}"; + } } diff --git a/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs b/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs index 0c140946d..eebc71eec 100644 --- a/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs @@ -11,10 +11,10 @@ namespace LibationUiBase.GridView /// The View Model for a LibraryBook that is ContentType.Parent public class SeriesEntry : GridEntry, ISeriesEntry where TStatus : IEntryStatus { - public List Children { get; } + public List Children { get; } public override DateTime DateAdded => Children.Max(c => c.DateAdded); - private bool suspendCounting = false; + private bool suspendCounting; public void ChildRemoveUpdate() { if (suspendCounting) return; @@ -42,7 +42,7 @@ public override bool? Remove } } - public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent, new[] { child }) { } + public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent, [child]) { } public SeriesEntry(LibraryBook parent, IEnumerable children) { LastDownload = new(); @@ -118,7 +118,7 @@ public void RemoveChild(ILibraryBookEntry lbe) Length = GetBookLengthString(); } - protected override string GetBookTags() => null; + protected override string GetBookTags() => null; protected override int GetLengthInMinutes() => Children.Count == 0 ? 0 : Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); protected override DateTime GetPurchaseDate() => Children.Count == 0 ? default : Children.Min(c => c.LibraryBook.DateAdded); } diff --git a/Source/LibationUiBase/LibationUiBase.csproj b/Source/LibationUiBase/LibationUiBase.csproj index ce4a3b512..ca4f9b228 100644 --- a/Source/LibationUiBase/LibationUiBase.csproj +++ b/Source/LibationUiBase/LibationUiBase.csproj @@ -9,6 +9,7 @@ + diff --git a/Source/LibationUiBase/RelayCommand.cs b/Source/LibationUiBase/RelayCommand.cs new file mode 100644 index 000000000..e3217162d --- /dev/null +++ b/Source/LibationUiBase/RelayCommand.cs @@ -0,0 +1,40 @@ +using System; +using System.Windows.Input; +using LibationUiBase.ViewModels; + +namespace LibationUiBase; + +public class RelayCommand : ICommand +{ + private Action execute; + private Func canExecute; + private ICanExecuteChanged canExecuteChanged; + + public event EventHandler CanExecuteChanged + { + add { canExecuteChanged.Event += value; } + remove { canExecuteChanged.Event -= value; } + } + + public RelayCommand(Action execute, Func canExecute = null) + { + this.execute = execute; + this.canExecute = canExecute; + canExecuteChanged = ServiceLocator.Get(); + } + + public bool CanExecute(object parameter) + { + return canExecute == null || canExecute(parameter); + } + + public void Execute(object parameter) + { + execute(parameter); + } + + public void RaiseCanExecuteChanged() + { + canExecuteChanged.Raise(); + } +} \ No newline at end of file diff --git a/Source/LibationUiBase/ServiceLocator.cs b/Source/LibationUiBase/ServiceLocator.cs new file mode 100644 index 000000000..b825f22a3 --- /dev/null +++ b/Source/LibationUiBase/ServiceLocator.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Linq; +using Prism.Events; + +namespace LibationUiBase; + +public static class ServiceLocator +{ + private static readonly ServiceCollection MutableContainer = new(); + private static ServiceProvider _container; + + /// + /// Registers a type that when requested, will always return the same, single instance. + /// + /// + public static void RegisterSingleton(Type t) + { + CheckContainerNotBuilt(); + if (!TypeExists(t)) + MutableContainer.AddSingleton(t); + } + + /// + /// Registers a type that when requested, will always return the same, single instance. + /// + public static void RegisterSingleton() where T : class + { + CheckContainerNotBuilt(); + if (!TypeExists(typeof(T))) + MutableContainer.AddSingleton(typeof(T)); + } + + /// + /// Registers a type that when requested, will always return the same, single instance. + /// + public static void RegisterSingleton() + where TInterface : class + where TImplementation : TInterface + { + CheckContainerNotBuilt(); + if (!TypeExists(typeof(TInterface))) + MutableContainer.AddSingleton(typeof(TInterface), typeof(TImplementation)); + } + + + /// + /// Registers a singleton but using a certain instance. + /// + /// + public static void RegisterInstance(object obj) + where TInterface : class + where TImplementation : TInterface + { + CheckContainerNotBuilt(); + if (!TypeExists(typeof(TInterface))) + MutableContainer.AddSingleton(_ => (TImplementation)obj); + } + + /// + /// Registers a type that gets a new instance every + /// call to Get(). + /// + /// + public static void RegisterTransient(Type t) + { + CheckContainerNotBuilt(); + if (!TypeExists(t)) + MutableContainer.AddTransient(t); + } + + /// + /// Registers a type that gets a new instance every + /// call to Get(). + /// + public static void RegisterTransient() + { + CheckContainerNotBuilt(); + if (!TypeExists(typeof(T))) + MutableContainer.AddTransient(typeof(T)); + } + + public static void RegisterTransient() + { + CheckContainerNotBuilt(); + if (!TypeExists(typeof(TInterface))) + MutableContainer.AddTransient(typeof(TInterface), typeof(TImplementation)); + } + + + + /// + /// Call once at app startup after add all required types. + /// + public static void AddCommonServicesAndBuild() + { + CheckContainerNotBuilt(); + + // Add common, cross-platform services here. + RegisterSingleton(); + + // Finalize and build container, mutableContainer should + // no longer be used. + _container = MutableContainer.BuildServiceProvider(); + } + + public static T Get() + { + CheckContainerBuilt(); + return _container.GetService(); + } + + public static object Get(Type type) + { + CheckContainerBuilt(); + if (!TypeExists(type)) + throw new InvalidOperationException($"Type {type.Name} has not been registered."); + return _container.GetService(type); + } + + private static bool TypeExists(Type type) + { + var exists = MutableContainer.Any(sd => sd.ServiceType == type); + if (exists) + Serilog.Log.Logger.Warning($"Type already registered: {type.Name}"); + return exists; + } + + private static void CheckContainerNotBuilt() + { + if (_container != null) + throw new Exception("Container already built!"); + } + + private static void CheckContainerBuilt() + { + if (_container == null) + throw new Exception("Container not yet built!"); + } +} \ No newline at end of file diff --git a/Source/LibationUiBase/ViewModels/ICanExecuteChanged.cs b/Source/LibationUiBase/ViewModels/ICanExecuteChanged.cs new file mode 100644 index 000000000..51b804666 --- /dev/null +++ b/Source/LibationUiBase/ViewModels/ICanExecuteChanged.cs @@ -0,0 +1,9 @@ +using System; + +namespace LibationUiBase.ViewModels; + +public interface ICanExecuteChanged +{ + event EventHandler Event; + void Raise(); +} \ No newline at end of file diff --git a/Source/LibationUiBase/ViewModels/Player/PlayerEvents.cs b/Source/LibationUiBase/ViewModels/Player/PlayerEvents.cs new file mode 100644 index 000000000..9a95d5cc7 --- /dev/null +++ b/Source/LibationUiBase/ViewModels/Player/PlayerEvents.cs @@ -0,0 +1,27 @@ +using LibationUiBase.GridView; +using Prism.Events; + +namespace LibationUiBase.ViewModels.Player +{ + public class BookAddedToPlaylist : PubSubEvent + { + public ILibraryBookEntry Book { get; } + + public BookAddedToPlaylist() {} + public BookAddedToPlaylist(ILibraryBookEntry book) + { + Book = book; + } + } + + public class BookRemovedFromPlaylist : PubSubEvent + { + public ILibraryBookEntry Book { get; } + + public BookRemovedFromPlaylist() {} + public BookRemovedFromPlaylist(ILibraryBookEntry book) + { + Book = book; + } + } +} diff --git a/Source/LibationUiBase/ViewModels/Player/PlayerViewModel.cs b/Source/LibationUiBase/ViewModels/Player/PlayerViewModel.cs new file mode 100644 index 000000000..76e137f35 --- /dev/null +++ b/Source/LibationUiBase/ViewModels/Player/PlayerViewModel.cs @@ -0,0 +1,123 @@ +using LibationUiBase.GridView; +using Prism.Events; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationUiBase.ViewModels.Player; + +public class PlayerViewModel : ViewModelBase +{ + private readonly IEventAggregator eventAggregator; + private BindingList playlistItems = new(); + /// + /// Public for data binding reasons. Do NOT manipulate directly. + /// + public BindingList PlaylistItems + { + get => playlistItems; + set => RaiseAndSetIfChanged(ref playlistItems, value); + } + + private PlaylistEntryViewModel selectedBook; + public PlaylistEntryViewModel SelectedBook + { + get => selectedBook; + set => RaiseAndSetIfChanged(ref selectedBook, value); + } + + public RelayCommand MoveUpCommand; + public RelayCommand MoveDownCommand; + + public PlayerViewModel(IEventAggregator eventAggregator) + { + MoveUpCommand = new RelayCommand(_ => + { + playlistItems.MoveUp(SelectedBook); + RenumberPlaylist(); + var temp = SelectedBook; + SelectedBook = null; + SelectedBook = temp; + }, _ => SelectedBook != null && playlistItems.IndexOf(SelectedBook) > 0 && + IsInPlaylist(SelectedBook)); + + MoveDownCommand = new RelayCommand(_ => + { + playlistItems.MoveDown(SelectedBook); + RenumberPlaylist(); + var temp = SelectedBook; + SelectedBook = null; + SelectedBook = temp; + }, _ => SelectedBook != null && playlistItems.IndexOf(SelectedBook) < playlistItems.Count - 1 && + IsInPlaylist(SelectedBook)); + + this.eventAggregator = eventAggregator; + } + + private void RenumberPlaylist() + { + for (var i = 0; i < playlistItems.Count; i++) + playlistItems[i].Sequence = i + 1; + } + + public async ValueTask AddToPlaylist(ILibraryBookEntry book) + { + var plevm = ServiceLocator.Get(); + await plevm.Init(book); + plevm.Sequence = playlistItems.Count + 1; + playlistItems.Add(plevm); + //eventAggregator.GetEvent().Publish(book); + } + + public ValueTask RemoveFromPlaylist(ILibraryBookEntry book) + { + var plevm = playlistItems.FirstOrDefault(b => b.BookEntry.AudibleProductId == book.Book.AudibleProductId); + if (plevm != null) + { + playlistItems.Remove(plevm); + InvalidateCommands(); + //eventAggregator.GetEvent().Publish(book); + } + + return ValueTask.CompletedTask; + } + + private void InvalidateCommands() + { + MoveDownCommand.RaiseCanExecuteChanged(); + MoveUpCommand.RaiseCanExecuteChanged(); + } + + public bool IsInPlaylist(ILibraryBookEntry book) => + PlaylistItems.Any(item => item.BookEntry.AudibleProductId == book.AudibleProductId); + + public bool IsInPlaylist(PlaylistEntryViewModel book) => + PlaylistItems.Any(item => item.BookEntry.AudibleProductId == book.BookEntry.AudibleProductId); + + + public override string ToString() => $"{nameof(PlayerViewModel)}: {string.Join(", ", playlistItems)}"; + + protected override void OnPropertyChanging(string propertyName) + { + switch (propertyName) + { + case nameof(SelectedBook): + if (SelectedBook != null) + SelectedBook.IsCurrent = false; + InvalidateCommands(); + break; + } + } + + protected override void OnPropertyChanged(string propertyName) + { + switch (propertyName) + { + case nameof(SelectedBook): + if (SelectedBook != null) + SelectedBook.IsCurrent = true; + InvalidateCommands(); + break; + } + } +} diff --git a/Source/LibationUiBase/ViewModels/Player/PlaylistEntryViewModel.cs b/Source/LibationUiBase/ViewModels/Player/PlaylistEntryViewModel.cs new file mode 100644 index 000000000..eadb180dc --- /dev/null +++ b/Source/LibationUiBase/ViewModels/Player/PlaylistEntryViewModel.cs @@ -0,0 +1,72 @@ +using LibationUiBase.GridView; +using System.Threading.Tasks; + +namespace LibationUiBase.ViewModels.Player; + +public class PlaylistEntryViewModel : ViewModelBase +{ + const string CurrentIndicator = "▶"; + + private int sequence; + public int Sequence + { + get => sequence; + set => RaiseAndSetIfChanged(ref sequence, value); + } + + private string seriesName = string.Empty; + public string SeriesName + { + get => seriesName; + set => RaiseAndSetIfChanged(ref seriesName, value); + } + + public string Title => bookEntry?.Title; + public string Series => bookEntry?.Series; + + private bool isCurrent; + public bool IsCurrent + { + get => isCurrent; + set => this.RaiseAndSetIfChanged(ref isCurrent, value); + } + + // "►" indicator for winforms + private string isCurrentStr = string.Empty; + public string IsCurrentStr + { + get => isCurrentStr; + set => this.RaiseAndSetIfChanged(ref isCurrentStr, value); + } + + private ILibraryBookEntry bookEntry; + public ILibraryBookEntry BookEntry + { + get => bookEntry; + set => RaiseAndSetIfChanged(ref bookEntry, value); + } + + public async ValueTask Init(ILibraryBookEntry bookEntry) + { + BookEntry = bookEntry; + } + + public override string ToString() => Title; + + protected override void OnPropertyChanged(string propertyName) + { + switch (propertyName) + { + case nameof(IsCurrent): + IsCurrentStr = IsCurrent ? CurrentIndicator : null; + break; + } + } + + public override bool Equals(object obj) + { + if (obj is PlaylistEntryViewModel plevm && bookEntry != null) + return bookEntry?.AudibleProductId == plevm?.bookEntry.AudibleProductId; + return false; + } +} \ No newline at end of file diff --git a/Source/LibationUiBase/ViewModels/ViewModelBase.cs b/Source/LibationUiBase/ViewModels/ViewModelBase.cs new file mode 100644 index 000000000..7795bb375 --- /dev/null +++ b/Source/LibationUiBase/ViewModels/ViewModelBase.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace LibationUiBase.ViewModels; + +public abstract class ViewModelBase : INotifyPropertyChanged, INotifyPropertyChanging +{ + public ViewModelBase() + { + this.PropertyChanged += (_, e) => OnPropertyChanged(e.PropertyName); + this.PropertyChanging += (_, e) => OnPropertyChanging(e.PropertyName); + } + + protected virtual void OnPropertyChanging(string propertyName) { } + protected virtual void OnPropertyChanged(string propertyName) { } + + public TRet RaiseAndSetIfChanged(ref TRet backingField, TRet newValue, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(backingField, newValue)) return newValue; + + RaisePropertyChanging(propertyName); + backingField = newValue; + RaisePropertyChanged(propertyName); + return newValue; + } + + public event PropertyChangedEventHandler PropertyChanged; + public void RaisePropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + public event PropertyChangingEventHandler PropertyChanging; + public void RaisePropertyChanging(string propertyName) => PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName)); +} + diff --git a/Source/LibationWinForms/Form1.Designer.cs b/Source/LibationWinForms/Form1.Designer.cs index 0c1473894..7da7de496 100644 --- a/Source/LibationWinForms/Form1.Designer.cs +++ b/Source/LibationWinForms/Form1.Designer.cs @@ -85,7 +85,7 @@ private void InitializeComponent() this.toggleQueueHideBtn = new System.Windows.Forms.Button(); this.doneRemovingBtn = new System.Windows.Forms.Button(); this.removeBooksBtn = new System.Windows.Forms.Button(); - this.processBookQueue1 = new LibationWinForms.ProcessQueue.ProcessQueueControl(); + this.processBookQueue1 = new LibationWinForms.ProcessQueue.SidebarControl(); this.menuStrip1.SuspendLayout(); this.statusStrip1.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit(); @@ -691,7 +691,7 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripMenuItem launchHangoverToolStripMenuItem; private LibationWinForms.FormattableToolStripMenuItem liberateVisibleToolStripMenuItem_LiberateMenu; private System.Windows.Forms.SplitContainer splitContainer1; - private LibationWinForms.ProcessQueue.ProcessQueueControl processBookQueue1; + private LibationWinForms.ProcessQueue.SidebarControl processBookQueue1; private System.Windows.Forms.Panel panel1; private System.Windows.Forms.Button toggleQueueHideBtn; public LibationWinForms.GridView.ProductsDisplay productsDisplay; diff --git a/Source/LibationWinForms/GridView/DataGridViewEx.cs b/Source/LibationWinForms/GridView/DataGridViewEx.cs new file mode 100644 index 000000000..285407af0 --- /dev/null +++ b/Source/LibationWinForms/GridView/DataGridViewEx.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq.Expressions; +using System.Windows.Forms; + +namespace LibationWinForms.GridView; + +/// +/// A DataGridView with a bindable SelectedItem property. +/// +public class DataGridViewEx : DataGridView, INotifyPropertyChanged +{ + private BindingSource bindingSource; + + private object selectedItem; + /// + /// Can bind to this. + /// + public object SelectedItem + { + get => DbNullToNull(selectedItem); + set => SetCurrentItem(DbNullToNull(value)); + } + + public DataGridViewEx() + { + this.DefaultCellStyle.DataSourceNullValue = null; + EditMode = DataGridViewEditMode.EditProgrammatically; + MultiSelect = false; + ColumnHeadersDefaultCellStyle.SelectionBackColor = + ColumnHeadersDefaultCellStyle.BackColor; + SelectionMode = DataGridViewSelectionMode.FullRowSelect; + this.DataSourceChanged += DataGridViewEx_DataSourceChanged; + } + + public void AddSelectedItemBinding(T vm, Expression> selectedItemProperty) + { + try + { + var prop = GetPropertyName(selectedItemProperty); + this.DataBindings.Add(nameof(SelectedItem), vm, prop); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + static string GetPropertyName(Expression> propertyExpression) + { + var memberExpression = propertyExpression.Body as MemberExpression; + + if (memberExpression == null) + { + if (propertyExpression.Body is UnaryExpression unaryExpression) + { + memberExpression = unaryExpression.Operand as MemberExpression; + } + } + + if (memberExpression != null) + return memberExpression.Member.Name; + + throw new ArgumentException($"Property expression is not a member access: {propertyExpression}", nameof(propertyExpression)); + } + + private void DataGridViewEx_DataSourceChanged(object sender, EventArgs e) + { + try + { + // Remove events from old binding source. + if (this.bindingSource is not null) + { + this.bindingSource.CurrencyManager.PositionChanged -= CurrencyManager_PositionChanged; + this.bindingSource.CurrencyManager.ListChanged -= CurrencyManager_ListChanged; + } + + if (DataSource is BindingSource bs) + { + // Set up events on new binding source. + this.bindingSource = bs; + bs.CurrencyManager.PositionChanged += CurrencyManager_PositionChanged; + bs.CurrencyManager.ListChanged += CurrencyManager_ListChanged; + this.DataBindings.DefaultDataSourceUpdateMode = DataSourceUpdateMode.OnPropertyChanged; + RaisePropertyChanged(nameof(SelectedItem)); + } + else + throw new InvalidOperationException($"{nameof(DataGridViewEx)} data source must be a BindingSource in order to handle selection."); + } + catch (Exception exception) + { + Console.WriteLine(exception); + throw; + } + } + + private void CurrencyManager_PositionChanged(object sender, EventArgs args) + { + var current = GetCurrent(); + if (current != null && current != SelectedItem) + SelectedItem = current; + } + + private void CurrencyManager_ListChanged(object sender, ListChangedEventArgs args) + { + if (args.ListChangedType == ListChangedType.Reset || + args.ListChangedType == ListChangedType.ItemDeleted) + { + if (this.bindingSource.Position == -1) + { + selectedItem = null; + } + if (SelectedRows.Count > 0) + BeginInvoke(new MethodInvoker(() => + { + if (SelectedRows.Count > 0) + SelectedItem = SelectedRows[0].DataBoundItem; + })); + } + } + + private object GetCurrent() + { + try + { + return bindingSource.Count == 0 || + bindingSource.CurrencyManager.Position == -1 || + bindingSource.CurrencyManager.Position >= Rows.Count + ? null + : DbNullToNull(Rows[bindingSource.CurrencyManager.Position].DataBoundItem); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + private void SetCurrentItem(object dataItem) + { + try + { + if (dataItem == null) + { + if (selectedItem != null) + { + selectedItem = null; + if (SelectedRows.Count > 0) + BeginInvoke(new MethodInvoker(ClearSelection)); + } + return; + } + + selectedItem = dataItem; + + for (var index = 0; index < Rows.Count; index++) + { + var row = Rows[index]; + + // Change the physically selected row in the grid. + if (CurrentRow == null || + row.DataBoundItem == dataItem) + { + BeginInvoke(new MethodInvoker(() => + CurrentCell = row.Cells[0])); + return; + } + } + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + finally + { + RaisePropertyChanged(nameof(SelectedItem)); + } + } + + private object DbNullToNull(object dataItem) + { + return dataItem is DBNull ? null: dataItem; + } + + List handlers = new(); + public event PropertyChangedEventHandler PropertyChanged + { + add { handlers.Add(value); } + remove { handlers.Remove(value); } + } + + private void RaisePropertyChanged(string propertyName) + { + try + { + var ev = new PropertyChangedEventArgs(propertyName); + foreach (var handler in handlers) + handler(this, ev); + } + catch (Exception e) + { + Console.WriteLine(e); + // Eat DBNull conversion bug. + } + + } +} \ No newline at end of file diff --git a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs index d3516228c..0290eb7c9 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs @@ -22,292 +22,322 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } - #region Component Designer generated code + #region Component Designer generated code - /// - /// Required method for Designer support - do not modify - /// the contents of this method with the code editor. - /// - private void InitializeComponent() - { - components = new System.ComponentModel.Container(); - System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle2 = new System.Windows.Forms.DataGridViewCellStyle(); - System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle1 = new System.Windows.Forms.DataGridViewCellStyle(); - gridEntryDataGridView = new System.Windows.Forms.DataGridView(); - removeGVColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn(); - liberateGVColumn = new LiberateDataGridViewImageButtonColumn(); - coverGVColumn = new System.Windows.Forms.DataGridViewImageColumn(); - titleGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - authorsGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - narratorsGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - lengthGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - seriesGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - seriesOrderGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - descriptionGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - categoryGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - productRatingGVColumn = new MyRatingGridViewColumn(); - purchaseDateGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - myRatingGVColumn = new MyRatingGridViewColumn(); - miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - lastDownloadedGVColumn = new LastDownloadedGridViewColumn(); - tagAndDetailsGVColumn = new EditTagsDataGridViewImageButtonColumn(); - showHideColumnsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(components); - syncBindingSource = new SyncBindingSource(components); - ((System.ComponentModel.ISupportInitialize)gridEntryDataGridView).BeginInit(); - ((System.ComponentModel.ISupportInitialize)syncBindingSource).BeginInit(); - SuspendLayout(); - // - // gridEntryDataGridView - // - gridEntryDataGridView.AllowUserToAddRows = false; - gridEntryDataGridView.AllowUserToDeleteRows = false; - gridEntryDataGridView.AllowUserToOrderColumns = true; - gridEntryDataGridView.AllowUserToResizeRows = false; - gridEntryDataGridView.AutoGenerateColumns = false; - gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; - gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { removeGVColumn, liberateGVColumn, coverGVColumn, titleGVColumn, authorsGVColumn, narratorsGVColumn, lengthGVColumn, seriesGVColumn, seriesOrderGVColumn, descriptionGVColumn, categoryGVColumn, productRatingGVColumn, purchaseDateGVColumn, myRatingGVColumn, miscGVColumn, lastDownloadedGVColumn, tagAndDetailsGVColumn }); - gridEntryDataGridView.ContextMenuStrip = showHideColumnsContextMenuStrip; - gridEntryDataGridView.DataSource = syncBindingSource; - dataGridViewCellStyle2.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft; - dataGridViewCellStyle2.BackColor = System.Drawing.SystemColors.Window; - dataGridViewCellStyle2.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - dataGridViewCellStyle2.ForeColor = System.Drawing.SystemColors.ControlText; - dataGridViewCellStyle2.SelectionBackColor = System.Drawing.SystemColors.Highlight; - dataGridViewCellStyle2.SelectionForeColor = System.Drawing.SystemColors.HighlightText; - dataGridViewCellStyle2.WrapMode = System.Windows.Forms.DataGridViewTriState.True; - gridEntryDataGridView.DefaultCellStyle = dataGridViewCellStyle2; - gridEntryDataGridView.Dock = System.Windows.Forms.DockStyle.Fill; - gridEntryDataGridView.EditMode = System.Windows.Forms.DataGridViewEditMode.EditOnEnter; - gridEntryDataGridView.Location = new System.Drawing.Point(0, 0); - gridEntryDataGridView.Margin = new System.Windows.Forms.Padding(6); - gridEntryDataGridView.Name = "gridEntryDataGridView"; - gridEntryDataGridView.RowHeadersVisible = false; - gridEntryDataGridView.RowHeadersWidth = 82; - gridEntryDataGridView.RowTemplate.Height = 82; - gridEntryDataGridView.Size = new System.Drawing.Size(3140, 760); - gridEntryDataGridView.TabIndex = 0; - gridEntryDataGridView.CellContentClick += DataGridView_CellContentClick; - gridEntryDataGridView.CellToolTipTextNeeded += gridEntryDataGridView_CellToolTipTextNeeded; - // - // removeGVColumn - // - removeGVColumn.DataPropertyName = "Remove"; - removeGVColumn.FalseValue = ""; - removeGVColumn.Frozen = true; - removeGVColumn.HeaderText = "Remove"; - removeGVColumn.IndeterminateValue = ""; - removeGVColumn.MinimumWidth = 60; - removeGVColumn.Name = "removeGVColumn"; - removeGVColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; - removeGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; - removeGVColumn.ThreeState = true; - removeGVColumn.TrueValue = ""; - removeGVColumn.Width = 60; - // - // liberateGVColumn - // - liberateGVColumn.DataPropertyName = "Liberate"; - liberateGVColumn.HeaderText = "Liberate"; - liberateGVColumn.MinimumWidth = 10; - liberateGVColumn.Name = "liberateGVColumn"; - liberateGVColumn.ReadOnly = true; - liberateGVColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; - liberateGVColumn.ScaleFactor = 0F; - liberateGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; - liberateGVColumn.Width = 75; - // - // coverGVColumn - // - coverGVColumn.DataPropertyName = "Cover"; - coverGVColumn.HeaderText = "Cover"; - coverGVColumn.ImageLayout = System.Windows.Forms.DataGridViewImageCellLayout.Zoom; - coverGVColumn.MinimumWidth = 10; - coverGVColumn.Name = "coverGVColumn"; - coverGVColumn.ReadOnly = true; - coverGVColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; - coverGVColumn.ToolTipText = "Cover Art"; - coverGVColumn.Width = 80; - // - // titleGVColumn - // - titleGVColumn.DataPropertyName = "Title"; - titleGVColumn.HeaderText = "Title"; - titleGVColumn.MinimumWidth = 10; - titleGVColumn.Name = "titleGVColumn"; - titleGVColumn.ReadOnly = true; - titleGVColumn.Width = 200; - // - // authorsGVColumn - // - authorsGVColumn.DataPropertyName = "Authors"; - authorsGVColumn.HeaderText = "Authors"; - authorsGVColumn.MinimumWidth = 10; - authorsGVColumn.Name = "authorsGVColumn"; - authorsGVColumn.ReadOnly = true; - authorsGVColumn.Width = 100; - // - // narratorsGVColumn - // - narratorsGVColumn.DataPropertyName = "Narrators"; - narratorsGVColumn.HeaderText = "Narrators"; - narratorsGVColumn.MinimumWidth = 10; - narratorsGVColumn.Name = "narratorsGVColumn"; - narratorsGVColumn.ReadOnly = true; - narratorsGVColumn.Width = 100; - // - // lengthGVColumn - // - lengthGVColumn.DataPropertyName = "Length"; - lengthGVColumn.HeaderText = "Length"; - lengthGVColumn.MinimumWidth = 10; - lengthGVColumn.Name = "lengthGVColumn"; - lengthGVColumn.ReadOnly = true; - lengthGVColumn.ToolTipText = "Recording Length"; - lengthGVColumn.Width = 100; - // - // seriesGVColumn - // - seriesGVColumn.DataPropertyName = "Series"; - seriesGVColumn.HeaderText = "Series"; - seriesGVColumn.MinimumWidth = 10; - seriesGVColumn.Name = "seriesGVColumn"; - seriesGVColumn.ReadOnly = true; - seriesGVColumn.Width = 100; - // - // seriesOrderGVColumn - // - seriesOrderGVColumn.DataPropertyName = "SeriesOrder"; - dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleCenter; - seriesOrderGVColumn.DefaultCellStyle = dataGridViewCellStyle1; - seriesOrderGVColumn.HeaderText = "Series\r\nOrder"; - seriesOrderGVColumn.MinimumWidth = 10; - seriesOrderGVColumn.Name = "seriesOrderGVColumn"; - seriesOrderGVColumn.ReadOnly = true; - seriesOrderGVColumn.Width = 60; - // - // descriptionGVColumn - // - descriptionGVColumn.DataPropertyName = "Description"; - descriptionGVColumn.HeaderText = "Description"; - descriptionGVColumn.MinimumWidth = 10; - descriptionGVColumn.Name = "descriptionGVColumn"; - descriptionGVColumn.ReadOnly = true; - descriptionGVColumn.Width = 100; - // - // categoryGVColumn - // - categoryGVColumn.DataPropertyName = "Category"; - categoryGVColumn.HeaderText = "Category"; - categoryGVColumn.MinimumWidth = 10; - categoryGVColumn.Name = "categoryGVColumn"; - categoryGVColumn.ReadOnly = true; - categoryGVColumn.Width = 100; - // - // productRatingGVColumn - // - productRatingGVColumn.DataPropertyName = "ProductRating"; - productRatingGVColumn.HeaderText = "Product Rating"; - productRatingGVColumn.MinimumWidth = 10; - productRatingGVColumn.Name = "productRatingGVColumn"; - productRatingGVColumn.ReadOnly = true; - productRatingGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; - productRatingGVColumn.Width = 112; - // - // purchaseDateGVColumn - // - purchaseDateGVColumn.DataPropertyName = "PurchaseDate"; - purchaseDateGVColumn.HeaderText = "Purchase Date"; - purchaseDateGVColumn.MinimumWidth = 10; - purchaseDateGVColumn.Name = "purchaseDateGVColumn"; - purchaseDateGVColumn.ReadOnly = true; - purchaseDateGVColumn.Width = 100; - // - // myRatingGVColumn - // - myRatingGVColumn.DataPropertyName = "MyRating"; - myRatingGVColumn.HeaderText = "My Rating"; - myRatingGVColumn.MinimumWidth = 10; - myRatingGVColumn.Name = "myRatingGVColumn"; - myRatingGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; - myRatingGVColumn.Width = 112; - // - // miscGVColumn - // - miscGVColumn.DataPropertyName = "Misc"; - miscGVColumn.HeaderText = "Misc"; - miscGVColumn.MinimumWidth = 10; - miscGVColumn.Name = "miscGVColumn"; - miscGVColumn.ReadOnly = true; - miscGVColumn.Width = 140; - // - // lastDownloadedGVColumn - // - lastDownloadedGVColumn.DataPropertyName = "LastDownload"; - lastDownloadedGVColumn.HeaderText = "Last Download"; - lastDownloadedGVColumn.MinimumWidth = 10; - lastDownloadedGVColumn.Name = "lastDownloadedGVColumn"; - lastDownloadedGVColumn.ReadOnly = true; - lastDownloadedGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; - lastDownloadedGVColumn.Width = 108; - // - // tagAndDetailsGVColumn - // - tagAndDetailsGVColumn.DataPropertyName = "BookTags"; - tagAndDetailsGVColumn.HeaderText = "Tags and Details"; - tagAndDetailsGVColumn.MinimumWidth = 10; - tagAndDetailsGVColumn.Name = "tagAndDetailsGVColumn"; - tagAndDetailsGVColumn.ReadOnly = true; - tagAndDetailsGVColumn.ScaleFactor = 0F; - tagAndDetailsGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; - tagAndDetailsGVColumn.Width = 100; - // - // showHideColumnsContextMenuStrip - // - showHideColumnsContextMenuStrip.ImageScalingSize = new System.Drawing.Size(32, 32); - showHideColumnsContextMenuStrip.Name = "contextMenuStrip1"; - showHideColumnsContextMenuStrip.ShowCheckMargin = true; - showHideColumnsContextMenuStrip.Size = new System.Drawing.Size(83, 4); - // - // syncBindingSource - // - syncBindingSource.DataSource = typeof(IGridEntry); - // - // ProductsGrid - // - AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F); - AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi; - AutoScroll = true; - Controls.Add(gridEntryDataGridView); - Name = "ProductsGrid"; - Size = new System.Drawing.Size(1570, 380); - Load += new System.EventHandler(ProductsGrid_Load); - ((System.ComponentModel.ISupportInitialize)(gridEntryDataGridView)).EndInit(); - ((System.ComponentModel.ISupportInitialize)(syncBindingSource)).EndInit(); - ResumeLayout(false); - - } + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle3 = new System.Windows.Forms.DataGridViewCellStyle(); + System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle4 = new System.Windows.Forms.DataGridViewCellStyle(); + System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle1 = new System.Windows.Forms.DataGridViewCellStyle(); + System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle2 = new System.Windows.Forms.DataGridViewCellStyle(); + gridEntryDataGridView = new System.Windows.Forms.DataGridView(); + removeGVColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn(); + liberateGVColumn = new LiberateDataGridViewImageButtonColumn(); + coverGVColumn = new System.Windows.Forms.DataGridViewImageColumn(); + playlistColumn = new System.Windows.Forms.DataGridViewButtonColumn(); + titleGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + authorsGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + narratorsGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + lengthGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + seriesGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + seriesOrderGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + descriptionGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + categoryGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + productRatingGVColumn = new MyRatingGridViewColumn(); + purchaseDateGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + myRatingGVColumn = new MyRatingGridViewColumn(); + miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + lastDownloadedGVColumn = new LastDownloadedGridViewColumn(); + tagAndDetailsGVColumn = new EditTagsDataGridViewImageButtonColumn(); + showHideColumnsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(components); + syncBindingSource = new SyncBindingSource(components); + ((System.ComponentModel.ISupportInitialize)gridEntryDataGridView).BeginInit(); + ((System.ComponentModel.ISupportInitialize)syncBindingSource).BeginInit(); + SuspendLayout(); + // + // gridEntryDataGridView + // + gridEntryDataGridView.AllowUserToAddRows = false; + gridEntryDataGridView.AllowUserToDeleteRows = false; + gridEntryDataGridView.AllowUserToOrderColumns = true; + gridEntryDataGridView.AllowUserToResizeRows = false; + gridEntryDataGridView.AutoGenerateColumns = false; + gridEntryDataGridView.BackgroundColor = System.Drawing.SystemColors.Window; + gridEntryDataGridView.BorderStyle = System.Windows.Forms.BorderStyle.None; + gridEntryDataGridView.CellBorderStyle = System.Windows.Forms.DataGridViewCellBorderStyle.None; + gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { removeGVColumn, liberateGVColumn, coverGVColumn, playlistColumn, titleGVColumn, authorsGVColumn, narratorsGVColumn, lengthGVColumn, seriesGVColumn, seriesOrderGVColumn, descriptionGVColumn, categoryGVColumn, productRatingGVColumn, purchaseDateGVColumn, myRatingGVColumn, miscGVColumn, lastDownloadedGVColumn, tagAndDetailsGVColumn }); + gridEntryDataGridView.ContextMenuStrip = showHideColumnsContextMenuStrip; + gridEntryDataGridView.DataSource = syncBindingSource; + dataGridViewCellStyle3.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft; + dataGridViewCellStyle3.BackColor = System.Drawing.SystemColors.Window; + dataGridViewCellStyle3.Font = new System.Drawing.Font("Segoe UI", 9F); + dataGridViewCellStyle3.ForeColor = System.Drawing.SystemColors.ControlText; + dataGridViewCellStyle3.SelectionBackColor = System.Drawing.SystemColors.Highlight; + dataGridViewCellStyle3.SelectionForeColor = System.Drawing.SystemColors.HighlightText; + dataGridViewCellStyle3.WrapMode = System.Windows.Forms.DataGridViewTriState.True; + gridEntryDataGridView.DefaultCellStyle = dataGridViewCellStyle3; + gridEntryDataGridView.Dock = System.Windows.Forms.DockStyle.Fill; + gridEntryDataGridView.EditMode = System.Windows.Forms.DataGridViewEditMode.EditOnEnter; + gridEntryDataGridView.Location = new System.Drawing.Point(0, 0); + gridEntryDataGridView.Margin = new System.Windows.Forms.Padding(10); + gridEntryDataGridView.MultiSelect = false; + gridEntryDataGridView.Name = "gridEntryDataGridView"; + gridEntryDataGridView.RowHeadersBorderStyle = System.Windows.Forms.DataGridViewHeaderBorderStyle.None; + gridEntryDataGridView.RowHeadersVisible = false; + gridEntryDataGridView.RowHeadersWidth = 82; + dataGridViewCellStyle4.BackColor = System.Drawing.SystemColors.Control; + gridEntryDataGridView.RowsDefaultCellStyle = dataGridViewCellStyle4; + gridEntryDataGridView.RowTemplate.Height = 82; + gridEntryDataGridView.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect; + gridEntryDataGridView.Size = new System.Drawing.Size(2748, 665); + gridEntryDataGridView.TabIndex = 0; + gridEntryDataGridView.CellContentClick += DataGridView_CellContentClick; + gridEntryDataGridView.CellToolTipTextNeeded += gridEntryDataGridView_CellToolTipTextNeeded; + gridEntryDataGridView.RowsAdded += gridEntryDataGridView_RowsAdded; + // + // removeGVColumn + // + removeGVColumn.AutoSizeMode = System.Windows.Forms.DataGridViewAutoSizeColumnMode.DisplayedCells; + removeGVColumn.DataPropertyName = "Remove"; + removeGVColumn.FalseValue = ""; + removeGVColumn.Frozen = true; + removeGVColumn.HeaderText = "Remove"; + removeGVColumn.IndeterminateValue = ""; + removeGVColumn.MinimumWidth = 60; + removeGVColumn.Name = "removeGVColumn"; + removeGVColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; + removeGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; + removeGVColumn.ThreeState = true; + removeGVColumn.TrueValue = ""; + removeGVColumn.Width = 187; + // + // liberateGVColumn + // + liberateGVColumn.DataPropertyName = "Liberate"; + liberateGVColumn.HeaderText = "Liberate"; + liberateGVColumn.MinimumWidth = 10; + liberateGVColumn.Name = "liberateGVColumn"; + liberateGVColumn.ReadOnly = true; + liberateGVColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; + liberateGVColumn.ScaleFactor = 0F; + liberateGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; + liberateGVColumn.Width = 75; + // + // coverGVColumn + // + coverGVColumn.DataPropertyName = "Cover"; + coverGVColumn.HeaderText = "Cover"; + coverGVColumn.ImageLayout = System.Windows.Forms.DataGridViewImageCellLayout.Zoom; + coverGVColumn.MinimumWidth = 10; + coverGVColumn.Name = "coverGVColumn"; + coverGVColumn.ReadOnly = true; + coverGVColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; + coverGVColumn.ToolTipText = "Cover Art"; + coverGVColumn.Width = 80; + // + // playlistColumn + // + playlistColumn.AutoSizeMode = System.Windows.Forms.DataGridViewAutoSizeColumnMode.DisplayedCells; + playlistColumn.DataPropertyName = "AddToPlaylistText"; + dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft; + dataGridViewCellStyle1.Padding = new System.Windows.Forms.Padding(8); + dataGridViewCellStyle1.WrapMode = System.Windows.Forms.DataGridViewTriState.False; + playlistColumn.DefaultCellStyle = dataGridViewCellStyle1; + playlistColumn.HeaderText = "Playlist"; + playlistColumn.MinimumWidth = 9; + playlistColumn.Name = "playlistColumn"; + playlistColumn.ReadOnly = true; + playlistColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; + playlistColumn.Text = "??????"; + playlistColumn.Width = 133; + // + // titleGVColumn + // + titleGVColumn.DataPropertyName = "Title"; + titleGVColumn.HeaderText = "Title"; + titleGVColumn.MinimumWidth = 10; + titleGVColumn.Name = "titleGVColumn"; + titleGVColumn.ReadOnly = true; + titleGVColumn.Width = 200; + // + // authorsGVColumn + // + authorsGVColumn.DataPropertyName = "Authors"; + authorsGVColumn.HeaderText = "Authors"; + authorsGVColumn.MinimumWidth = 10; + authorsGVColumn.Name = "authorsGVColumn"; + authorsGVColumn.ReadOnly = true; + authorsGVColumn.Width = 175; + // + // narratorsGVColumn + // + narratorsGVColumn.DataPropertyName = "Narrators"; + narratorsGVColumn.HeaderText = "Narrators"; + narratorsGVColumn.MinimumWidth = 10; + narratorsGVColumn.Name = "narratorsGVColumn"; + narratorsGVColumn.ReadOnly = true; + narratorsGVColumn.Width = 175; + // + // lengthGVColumn + // + lengthGVColumn.DataPropertyName = "Length"; + lengthGVColumn.HeaderText = "Length"; + lengthGVColumn.MinimumWidth = 10; + lengthGVColumn.Name = "lengthGVColumn"; + lengthGVColumn.ReadOnly = true; + lengthGVColumn.ToolTipText = "Recording Length"; + lengthGVColumn.Width = 175; + // + // seriesGVColumn + // + seriesGVColumn.DataPropertyName = "Series"; + seriesGVColumn.HeaderText = "Series"; + seriesGVColumn.MinimumWidth = 10; + seriesGVColumn.Name = "seriesGVColumn"; + seriesGVColumn.ReadOnly = true; + seriesGVColumn.Width = 175; + // + // seriesOrderGVColumn + // + seriesOrderGVColumn.DataPropertyName = "SeriesOrder"; + dataGridViewCellStyle2.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleCenter; + seriesOrderGVColumn.DefaultCellStyle = dataGridViewCellStyle2; + seriesOrderGVColumn.HeaderText = "Series\r\nOrder"; + seriesOrderGVColumn.MinimumWidth = 10; + seriesOrderGVColumn.Name = "seriesOrderGVColumn"; + seriesOrderGVColumn.ReadOnly = true; + seriesOrderGVColumn.Width = 60; + // + // descriptionGVColumn + // + descriptionGVColumn.DataPropertyName = "Description"; + descriptionGVColumn.HeaderText = "Description"; + descriptionGVColumn.MinimumWidth = 10; + descriptionGVColumn.Name = "descriptionGVColumn"; + descriptionGVColumn.ReadOnly = true; + descriptionGVColumn.Width = 175; + // + // categoryGVColumn + // + categoryGVColumn.DataPropertyName = "Category"; + categoryGVColumn.HeaderText = "Category"; + categoryGVColumn.MinimumWidth = 10; + categoryGVColumn.Name = "categoryGVColumn"; + categoryGVColumn.ReadOnly = true; + categoryGVColumn.Width = 175; + // + // productRatingGVColumn + // + productRatingGVColumn.DataPropertyName = "ProductRating"; + productRatingGVColumn.HeaderText = "Product Rating"; + productRatingGVColumn.MinimumWidth = 10; + productRatingGVColumn.Name = "productRatingGVColumn"; + productRatingGVColumn.ReadOnly = true; + productRatingGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; + productRatingGVColumn.Width = 112; + // + // purchaseDateGVColumn + // + purchaseDateGVColumn.DataPropertyName = "PurchaseDate"; + purchaseDateGVColumn.HeaderText = "Purchase Date"; + purchaseDateGVColumn.MinimumWidth = 10; + purchaseDateGVColumn.Name = "purchaseDateGVColumn"; + purchaseDateGVColumn.ReadOnly = true; + purchaseDateGVColumn.Width = 175; + // + // myRatingGVColumn + // + myRatingGVColumn.DataPropertyName = "MyRating"; + myRatingGVColumn.HeaderText = "My Rating"; + myRatingGVColumn.MinimumWidth = 10; + myRatingGVColumn.Name = "myRatingGVColumn"; + myRatingGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; + myRatingGVColumn.Width = 112; + // + // miscGVColumn + // + miscGVColumn.DataPropertyName = "Misc"; + miscGVColumn.HeaderText = "Misc"; + miscGVColumn.MinimumWidth = 10; + miscGVColumn.Name = "miscGVColumn"; + miscGVColumn.ReadOnly = true; + miscGVColumn.Width = 140; + // + // lastDownloadedGVColumn + // + lastDownloadedGVColumn.DataPropertyName = "LastDownload"; + lastDownloadedGVColumn.HeaderText = "Last Download"; + lastDownloadedGVColumn.MinimumWidth = 10; + lastDownloadedGVColumn.Name = "lastDownloadedGVColumn"; + lastDownloadedGVColumn.ReadOnly = true; + lastDownloadedGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; + lastDownloadedGVColumn.Width = 108; + // + // tagAndDetailsGVColumn + // + tagAndDetailsGVColumn.DataPropertyName = "BookTags"; + tagAndDetailsGVColumn.HeaderText = "Tags and Details"; + tagAndDetailsGVColumn.MinimumWidth = 10; + tagAndDetailsGVColumn.Name = "tagAndDetailsGVColumn"; + tagAndDetailsGVColumn.ReadOnly = true; + tagAndDetailsGVColumn.ScaleFactor = 0F; + tagAndDetailsGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; + tagAndDetailsGVColumn.Width = 175; + // + // showHideColumnsContextMenuStrip + // + showHideColumnsContextMenuStrip.ImageScalingSize = new System.Drawing.Size(32, 32); + showHideColumnsContextMenuStrip.Name = "contextMenuStrip1"; + showHideColumnsContextMenuStrip.ShowCheckMargin = true; + showHideColumnsContextMenuStrip.Size = new System.Drawing.Size(83, 4); + // + // syncBindingSource + // + syncBindingSource.DataSource = typeof(IGridEntry); + // + // ProductsGrid + // + AutoScaleDimensions = new System.Drawing.SizeF(168F, 168F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi; + AutoScroll = true; + Controls.Add(gridEntryDataGridView); + Margin = new System.Windows.Forms.Padding(5); + Name = "ProductsGrid"; + Size = new System.Drawing.Size(2748, 665); + Load += ProductsGrid_Load; + ((System.ComponentModel.ISupportInitialize)gridEntryDataGridView).EndInit(); + ((System.ComponentModel.ISupportInitialize)syncBindingSource).EndInit(); + ResumeLayout(false); + } - #endregion - private System.Windows.Forms.DataGridView gridEntryDataGridView; + #endregion + private System.Windows.Forms.DataGridView gridEntryDataGridView; private System.Windows.Forms.ContextMenuStrip showHideColumnsContextMenuStrip; private SyncBindingSource syncBindingSource; - private System.Windows.Forms.DataGridViewCheckBoxColumn removeGVColumn; - private LiberateDataGridViewImageButtonColumn liberateGVColumn; - private System.Windows.Forms.DataGridViewImageColumn coverGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn titleGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn authorsGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn narratorsGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn lengthGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn seriesGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn seriesOrderGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn descriptionGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn categoryGVColumn; - private MyRatingGridViewColumn productRatingGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGVColumn; - private MyRatingGridViewColumn myRatingGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn; - private LastDownloadedGridViewColumn lastDownloadedGVColumn; - private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn; - } + private System.Windows.Forms.DataGridViewCheckBoxColumn removeGVColumn; + private LiberateDataGridViewImageButtonColumn liberateGVColumn; + private System.Windows.Forms.DataGridViewImageColumn coverGVColumn; + private System.Windows.Forms.DataGridViewButtonColumn playlistColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn titleGVColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn authorsGVColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn narratorsGVColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn lengthGVColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn seriesGVColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn seriesOrderGVColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn descriptionGVColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn categoryGVColumn; + private MyRatingGridViewColumn productRatingGVColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGVColumn; + private MyRatingGridViewColumn myRatingGVColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn; + private LastDownloadedGridViewColumn lastDownloadedGVColumn; + private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn; + } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 790f5ba03..37254fe23 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -2,526 +2,628 @@ using Dinah.Core; using Dinah.Core.WindowsDesktop.Forms; using LibationFileManager; +using LibationUiBase; using LibationUiBase.GridView; -using NPOI.SS.Formula.Functions; +using LibationUiBase.ViewModels.Player; +using Prism.Events; using System; using System.Collections.Generic; -using System.Data; using System.Drawing; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; +using Color = System.Drawing.Color; namespace LibationWinForms.GridView { - public delegate void GridEntryClickedEventHandler(IGridEntry liveGridEntry); - public delegate void LibraryBookEntryClickedEventHandler(ILibraryBookEntry liveGridEntry); - public delegate void GridEntryRectangleClickedEventHandler(IGridEntry liveGridEntry, Rectangle cellRectangle); - public delegate void ProductsGridCellContextMenuStripNeededEventHandler(IGridEntry liveGridEntry, ContextMenuStrip ctxMenu); - - public partial class ProductsGrid : UserControl - { - /// Number of visible rows has changed - public event EventHandler VisibleCountChanged; - public event LibraryBookEntryClickedEventHandler LiberateClicked; - public event GridEntryClickedEventHandler CoverClicked; - public event LibraryBookEntryClickedEventHandler DetailsClicked; - public event GridEntryRectangleClickedEventHandler DescriptionClicked; - public new event EventHandler Scroll; - public event EventHandler RemovableCountChanged; - public event ProductsGridCellContextMenuStripNeededEventHandler LiberateContextMenuStripNeeded; - - private GridEntryBindingList bindingList; - internal IEnumerable GetVisibleBooks() - => bindingList - .GetFilteredInItems() - .Select(lbe => lbe.LibraryBook); - internal IEnumerable GetAllBookEntries() - => bindingList.AllItems().BookEntries(); - - public ProductsGrid() - { - InitializeComponent(); - EnableDoubleBuffering(); - gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s); - gridEntryDataGridView.CellContextMenuStripNeeded += GridEntryDataGridView_CellContextMenuStripNeeded; - removeGVColumn.Frozen = false; - - defaultFont = gridEntryDataGridView.DefaultCellStyle.Font; - setGridFontScale(Configuration.Instance.GridFontScaleFactor); - setGridScale(Configuration.Instance.GridScaleFactor); - Configuration.Instance.PropertyChanged += Configuration_ScaleChanged; - Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged; - - gridEntryDataGridView.Disposed += (_, _) => - { - Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged; - Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged; - }; - } - - #region Scaling - - [PropertyChangeFilter(nameof(Configuration.GridFontScaleFactor))] - private void Configuration_FontScaleChanged(object sender, PropertyChangedEventArgsEx e) - => setGridFontScale((float)e.NewValue); - - [PropertyChangeFilter(nameof(Configuration.GridScaleFactor))] - private void Configuration_ScaleChanged(object sender, PropertyChangedEventArgsEx e) - => setGridScale((float)e.NewValue); - - /// - /// Keep track of the original dimensions for rescaling - /// - private static readonly Dictionary originalDims = new(); - private readonly Font defaultFont; - private void setGridScale(float scale) - { - foreach (var col in gridEntryDataGridView.Columns.Cast()) - { - //Only resize fixed-width columns. The rest can be adjusted by users. - if (col.Resizable is DataGridViewTriState.False) - { - if (!originalDims.ContainsKey(col)) - originalDims[col] = col.Width; - - col.Width = this.DpiScale(originalDims[col], scale); - } - - if (col is IDataGridScaleColumn scCol) - scCol.ScaleFactor = scale; - } - - if (!originalDims.ContainsKey(gridEntryDataGridView.RowTemplate)) - originalDims[gridEntryDataGridView.RowTemplate] = gridEntryDataGridView.RowTemplate.Height; - - var height = gridEntryDataGridView.RowTemplate.Height = this.DpiScale(originalDims[gridEntryDataGridView.RowTemplate], scale); - - foreach (var row in gridEntryDataGridView.Rows.Cast()) - row.Height = height; - } - - private void setGridFontScale(float scale) - => gridEntryDataGridView.DefaultCellStyle.Font = new Font(defaultFont.FontFamily, defaultFont.Size * scale); - - #endregion - - private void GridEntryDataGridView_CellContextMenuStripNeeded(object sender, DataGridViewCellContextMenuStripNeededEventArgs e) - { - // header - if (e.RowIndex < 0) - return; - - e.ContextMenuStrip = new ContextMenuStrip(); - // any column except cover & stop light - if (e.ColumnIndex != liberateGVColumn.Index && e.ColumnIndex != coverGVColumn.Index) - { - e.ContextMenuStrip.Items.Add("Copy Cell Contents", null, (_, __) => - { - try - { - var dgv = (DataGridView)sender; - var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString(); - Clipboard.SetDataObject(text, false, 5, 150); - } - catch { } - }); - e.ContextMenuStrip.Items.Add(new ToolStripSeparator()); - } - - var entry = getGridEntry(e.RowIndex); - var name = gridEntryDataGridView.Columns[e.ColumnIndex].DataPropertyName; - LiberateContextMenuStripNeeded?.Invoke(entry, e.ContextMenuStrip); - } - - private void EnableDoubleBuffering() - { - var propertyInfo = gridEntryDataGridView.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - - propertyInfo.SetValue(gridEntryDataGridView, true, null); - } - - #region Button controls - private void DataGridView_CellContentClick(object sender, DataGridViewCellEventArgs e) - { - try - { - // handle grid button click: https://stackoverflow.com/a/13687844 - if (e.RowIndex < 0) - return; - - var entry = getGridEntry(e.RowIndex); - if (entry is ILibraryBookEntry lbEntry) - { - if (e.ColumnIndex == liberateGVColumn.Index) - LiberateClicked?.Invoke(lbEntry); - else if (e.ColumnIndex == tagAndDetailsGVColumn.Index) - DetailsClicked?.Invoke(lbEntry); - else if (e.ColumnIndex == descriptionGVColumn.Index) - DescriptionClicked?.Invoke(lbEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); - else if (e.ColumnIndex == coverGVColumn.Index) - CoverClicked?.Invoke(lbEntry); - } - else if (entry is ISeriesEntry sEntry) - { - if (e.ColumnIndex == liberateGVColumn.Index) - { - if (sEntry.Liberate.Expanded) - bindingList.CollapseItem(sEntry); - else - bindingList.ExpandItem(sEntry); - - VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); - } - else if (e.ColumnIndex == descriptionGVColumn.Index) - DescriptionClicked?.Invoke(sEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); - else if (e.ColumnIndex == coverGVColumn.Index) - CoverClicked?.Invoke(sEntry); - } - - if (e.ColumnIndex == removeGVColumn.Index) - { - gridEntryDataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit); - RemovableCountChanged?.Invoke(this, EventArgs.Empty); - } - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, $"An error was encountered while processing a user click in the {nameof(ProductsGrid)}"); - } - } - - private IGridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem(rowIndex); - - #endregion - - #region UI display functions - - internal bool RemoveColumnVisible - { - get => removeGVColumn.Visible; - set - { - if (value) - { - foreach (var book in bindingList.AllItems()) - book.Remove = false; - } - - removeGVColumn.DisplayIndex = 0; - removeGVColumn.Frozen = value; - removeGVColumn.Visible = value; - } - } - - internal async Task BindToGridAsync(List dbBooks) - { - var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks); - - var seriesEntries = await SeriesEntry.GetAllSeriesEntriesAsync(dbBooks); - - geList.AddRange(seriesEntries); - //Sort descending by date (default sort property) - var comparer = new RowComparer(); - geList.Sort((a, b) => comparer.Compare(b, a)); - - //Add all children beneath their parent - foreach (var series in seriesEntries) - { - var seriesIndex = geList.IndexOf(series); - foreach (var child in series.Children) - geList.Insert(++seriesIndex, child); - } - - bindingList = new GridEntryBindingList(geList); - bindingList.CollapseAll(); - syncBindingSource.DataSource = bindingList; - VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); - } - - internal void UpdateGrid(List dbBooks) - { - //First row that is in view in the DataGridView - var topRow = gridEntryDataGridView.Rows.Cast().FirstOrDefault(r => r.Displayed)?.Index ?? 0; - - #region Add new or update existing grid entries - - //Remove filter prior to adding/updating boooks - string existingFilter = syncBindingSource.Filter; - Filter(null); - - //Add absent entries to grid, or update existing entry - - var allEntries = bindingList.AllItems().BookEntries(); - var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); - var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet(); - - bindingList.RaiseListChangedEvents = false; - foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) - { - var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); - - if (libraryBook.Book.IsProduct()) - { - AddOrUpdateBook(libraryBook, existingEntry); - continue; - } - if (parentedEpisodes.Contains(libraryBook)) - { - //Only try to add or update is this LibraryBook is a know child of a parent - AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); - } - } - bindingList.RaiseListChangedEvents = true; - - //Re-apply filter after adding new/updating existing books to capture any changes - //The Filter call also ensures that the binding list is reset so the DataGridView - //is made aware of all changes that were made while RaiseListChangedEvents was false - Filter(existingFilter); - - #endregion - - // remove deleted from grid. - // note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this - var removedBooks = - bindingList - .AllItems() - .BookEntries() - .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); - - RemoveBooks(removedBooks); - - gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow; - } - - public void RemoveBooks(IEnumerable removedBooks) - { - //Remove books in series from their parents' Children list - foreach (var removed in removedBooks.Where(b => b.Liberate.IsEpisode)) - removed.Parent.RemoveChild(removed); - - //Remove series that have no children - var removedSeries = - bindingList - .AllItems() - .EmptySeries(); - - foreach (var removed in removedBooks.Cast().Concat(removedSeries)) - //no need to re-filter for removed books - bindingList.Remove(removed); - - VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); - } - - private void AddOrUpdateBook(LibraryBook book, ILibraryBookEntry existingBookEntry) - { - if (existingBookEntry is null) - // Add the new product to top - bindingList.Insert(0, new LibraryBookEntry(book)); - else - // update existing - existingBookEntry.UpdateLibraryBook(book); - } - - private void AddOrUpdateEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) - { - if (existingEpisodeEntry is null) - { - ILibraryBookEntry episodeEntry; - - var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); - - if (seriesEntry is null) - { - //Series doesn't exist yet, so create and add it - var seriesBook = dbBooks.FindSeriesParent(episodeBook); - - if (seriesBook is null) - { - //This is only possible if the user's db has some malformed - //entries from earlier Libation releases that could not be - //automatically fixed. Log, but don't throw. - Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames()); - return; - } - - seriesEntry = new SeriesEntry(seriesBook, episodeBook); - seriesEntries.Add(seriesEntry); - - episodeEntry = seriesEntry.Children[0]; - seriesEntry.Liberate.Expanded = true; - bindingList.Insert(0, seriesEntry); - } - else - { - //Series exists. Create and add episode child then update the SeriesEntry - episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry); - seriesEntry.Children.Add(episodeEntry); - seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex)); - var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); - seriesEntry.UpdateLibraryBook(seriesBook); - } - - //Series entry must be expanded so its child can - //be placed in the correct position beneath it. - var isExpanded = seriesEntry.Liberate.Expanded; - bindingList.ExpandItem(seriesEntry); - - //Add episode to the grid beneath the parent - int seriesIndex = bindingList.IndexOf(seriesEntry); - int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry); - bindingList.Insert(seriesIndex + 1 + episodeIndex, episodeEntry); - - if (isExpanded) - bindingList.ExpandItem(seriesEntry); - else - bindingList.CollapseItem(seriesEntry); - } - else - existingEpisodeEntry.UpdateLibraryBook(episodeBook); - } - - #endregion - - #region Filter - - public void Filter(string searchString) - { - if (bindingList is null) return; - - int visibleCount = bindingList.Count; - - if (string.IsNullOrEmpty(searchString)) - syncBindingSource.RemoveFilter(); - else - syncBindingSource.Filter = searchString; - - if (visibleCount != bindingList.Count) - VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); - } - - #endregion - - #region Column Customizations - - private void ProductsGrid_Load(object sender, EventArgs e) - { - //https://stackoverflow.com/a/4498512/3335599 - if (System.ComponentModel.LicenseManager.UsageMode == System.ComponentModel.LicenseUsageMode.Designtime) return; - - gridEntryDataGridView.ColumnWidthChanged += gridEntryDataGridView_ColumnWidthChanged; - gridEntryDataGridView.ColumnDisplayIndexChanged += gridEntryDataGridView_ColumnDisplayIndexChanged; - - showHideColumnsContextMenuStrip.Items.Add(new ToolStripLabel("Show / Hide Columns")); - showHideColumnsContextMenuStrip.Items.Add(new ToolStripSeparator()); - - //Restore Grid Display Settings - var config = Configuration.Instance; - var gridColumnsVisibilities = config.GridColumnsVisibilities; - var gridColumnsWidths = config.GridColumnsWidths; - var displayIndices = config.GridColumnsDisplayIndices; - - var cmsKiller = new ContextMenuStrip(); - - foreach (DataGridViewColumn column in gridEntryDataGridView.Columns) - { - var itemName = column.DataPropertyName; - var visible = gridColumnsVisibilities.GetValueOrDefault(itemName, true); - - var menuItem = new ToolStripMenuItem(column.HeaderText) - { - Checked = visible, - Tag = itemName - }; - menuItem.Click += HideMenuItem_Click; - showHideColumnsContextMenuStrip.Items.Add(menuItem); - - //Only set column widths for user resizable columns. - //Fixed column widths are set by setGridScale() - if (column.Resizable is not DataGridViewTriState.False) - column.Width = gridColumnsWidths.GetValueOrDefault(itemName, this.DpiScale(column.Width)); - - column.MinimumWidth = 10; - column.HeaderCell.ContextMenuStrip = showHideColumnsContextMenuStrip; - column.Visible = visible; - - //Setting a default ContextMenuStrip will allow the columns to handle the - //Show() event so it is not passed up to the _dataGridView.ContextMenuStrip. - //This allows the ContextMenuStrip to be shown if right-clicking in the gray - //background of _dataGridView but not shown if right-clicking inside cells. - column.ContextMenuStrip = cmsKiller; - } - - //We must set DisplayIndex properties in ascending order - foreach (var itemName in displayIndices.OrderBy(i => i.Value).Select(i => i.Key)) - { - var column = gridEntryDataGridView.Columns - .Cast() - .SingleOrDefault(c => c.DataPropertyName == itemName); - - if (column is null) continue; - - column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index); - } - - //Remove column is always first; - removeGVColumn.DisplayIndex = 0; - removeGVColumn.Visible = false; - removeGVColumn.ValueType = typeof(bool?); - removeGVColumn.FalseValue = false; - removeGVColumn.TrueValue = true; - removeGVColumn.IndeterminateValue = null; - } - - private void HideMenuItem_Click(object sender, EventArgs e) - { - var menuItem = sender as ToolStripMenuItem; - var propertyName = menuItem.Tag as string; - - var column = gridEntryDataGridView.Columns - .Cast() - .FirstOrDefault(c => c.DataPropertyName == propertyName); - - if (column != null) - { - var visible = menuItem.Checked; - menuItem.Checked = !visible; - column.Visible = !visible; - - var config = Configuration.Instance; - - var dictionary = config.GridColumnsVisibilities; - dictionary[propertyName] = column.Visible; - config.GridColumnsVisibilities = dictionary; - } - } - - private void gridEntryDataGridView_ColumnDisplayIndexChanged(object sender, DataGridViewColumnEventArgs e) - { - var config = Configuration.Instance; - - var dictionary = config.GridColumnsDisplayIndices; - dictionary[e.Column.DataPropertyName] = e.Column.DisplayIndex; - config.GridColumnsDisplayIndices = dictionary; - } - - private void gridEntryDataGridView_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e) - { - if (e.ColumnIndex == descriptionGVColumn.Index) - e.ToolTipText = "Click to see full description"; - else if (e.ColumnIndex == coverGVColumn.Index) - e.ToolTipText = "Click to see full size"; - } - - private void gridEntryDataGridView_ColumnWidthChanged(object sender, DataGridViewColumnEventArgs e) - { - var config = Configuration.Instance; - - var dictionary = config.GridColumnsWidths; - dictionary[e.Column.DataPropertyName] = e.Column.Width; - config.GridColumnsWidths = dictionary; - } - - #endregion - } + public delegate void GridEntryClickedEventHandler(IGridEntry liveGridEntry); + public delegate void LibraryBookEntryClickedEventHandler(ILibraryBookEntry liveGridEntry); + public delegate void GridEntryRectangleClickedEventHandler(IGridEntry liveGridEntry, Rectangle cellRectangle); + public delegate void ProductsGridCellContextMenuStripNeededEventHandler(IGridEntry liveGridEntry, ContextMenuStrip ctxMenu); + + public partial class ProductsGrid : UserControl + { + //IEventAggregator eventAggregator; + PlayerViewModel _player = ServiceLocator.Get(); + + /// Number of visible rows has changed + public event EventHandler VisibleCountChanged; + public event LibraryBookEntryClickedEventHandler LiberateClicked; + public event GridEntryClickedEventHandler CoverClicked; + public event LibraryBookEntryClickedEventHandler DetailsClicked; + public event GridEntryRectangleClickedEventHandler DescriptionClicked; + public new event EventHandler Scroll; + public event EventHandler RemovableCountChanged; + public event ProductsGridCellContextMenuStripNeededEventHandler LiberateContextMenuStripNeeded; + + private GridEntryBindingList bindingList; + internal IEnumerable GetVisibleBooks() + { + return bindingList + .GetFilteredInItems() + .Select(lbe => lbe.LibraryBook); + } + + internal IEnumerable GetAllBookEntries() + => bindingList.AllItems().BookEntries(); + + public ProductsGrid() + { + InitializeComponent(); + EnableDoubleBuffering(); + gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s); + gridEntryDataGridView.CellContextMenuStripNeeded += GridEntryDataGridView_CellContextMenuStripNeeded; + gridEntryDataGridView.CellClick += gridEntryDataGridView_CellClick; + removeGVColumn.Frozen = false; + + // Hide the column selection when you're clicking in cells. + gridEntryDataGridView.ColumnHeadersDefaultCellStyle.SelectionBackColor = + gridEntryDataGridView.ColumnHeadersDefaultCellStyle.BackColor; + gridEntryDataGridView.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + + defaultFont = gridEntryDataGridView.DefaultCellStyle.Font; + setGridFontScale(Configuration.Instance.GridFontScaleFactor); + setGridScale(Configuration.Instance.GridScaleFactor); + Configuration.Instance.PropertyChanged += Configuration_ScaleChanged; + Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged; + + gridEntryDataGridView.Disposed += (_, _) => + { + Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged; + Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged; + }; + + //eventAggregator = ServiceLocator.Get(); + //eventAggregator.GetEvent().Subscribe(InvalidateBookEntry, ThreadOption.PublisherThread); + //eventAggregator.GetEvent().Subscribe(InvalidateBookEntry, ThreadOption.PublisherThread); + } + + #region Scaling + + [PropertyChangeFilter(nameof(Configuration.GridFontScaleFactor))] + private void Configuration_FontScaleChanged(object sender, PropertyChangedEventArgsEx e) + => setGridFontScale((float)e.NewValue); + + [PropertyChangeFilter(nameof(Configuration.GridScaleFactor))] + private void Configuration_ScaleChanged(object sender, PropertyChangedEventArgsEx e) + => setGridScale((float)e.NewValue); + + /// + /// Keep track of the original dimensions for rescaling + /// + private static readonly Dictionary originalDims = new(); + private readonly Font defaultFont; + private void setGridScale(float scale) + { + foreach (var col in gridEntryDataGridView.Columns.Cast()) + { + //Only resize fixed-width columns. The rest can be adjusted by users. + if (col.Resizable is DataGridViewTriState.False) + { + if (!originalDims.ContainsKey(col)) + originalDims[col] = col.Width; + + col.Width = this.DpiScale(originalDims[col], scale); + } + + if (col is IDataGridScaleColumn scCol) + scCol.ScaleFactor = scale; + } + + if (!originalDims.ContainsKey(gridEntryDataGridView.RowTemplate)) + originalDims[gridEntryDataGridView.RowTemplate] = gridEntryDataGridView.RowTemplate.Height; + + var height = gridEntryDataGridView.RowTemplate.Height = this.DpiScale(originalDims[gridEntryDataGridView.RowTemplate], scale); + + foreach (var row in gridEntryDataGridView.Rows.Cast()) + row.Height = height; + } + + private void setGridFontScale(float scale) + => gridEntryDataGridView.DefaultCellStyle.Font = new Font(defaultFont.FontFamily, defaultFont.Size * scale); + + #endregion + + private void GridEntryDataGridView_CellContextMenuStripNeeded(object sender, DataGridViewCellContextMenuStripNeededEventArgs e) + { + // header + if (e.RowIndex < 0) + return; + + e.ContextMenuStrip = new ContextMenuStrip(); + // any column except cover & stop light + if (e.ColumnIndex != liberateGVColumn.Index && e.ColumnIndex != coverGVColumn.Index) + { + e.ContextMenuStrip.Items.Add("Copy Cell Contents", null, (_, __) => + { + try + { + var dgv = (DataGridView)sender; + var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString(); + Clipboard.SetDataObject(text, false, 5, 150); + } + catch { } + }); + e.ContextMenuStrip.Items.Add(new ToolStripSeparator()); + } + + var entry = getGridEntry(e.RowIndex); + var name = gridEntryDataGridView.Columns[e.ColumnIndex].DataPropertyName; + LiberateContextMenuStripNeeded?.Invoke(entry, e.ContextMenuStrip); + } + + private void EnableDoubleBuffering() + { + var propertyInfo = gridEntryDataGridView.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + + propertyInfo.SetValue(gridEntryDataGridView, true, null); + } + + #region Button controls + private void DataGridView_CellContentClick(object sender, DataGridViewCellEventArgs e) + { + try + { + // handle grid button click: https://stackoverflow.com/a/13687844 + if (e.RowIndex < 0) + return; + + var entry = getGridEntry(e.RowIndex); + if (entry is ILibraryBookEntry lbEntry) + { + if (e.ColumnIndex == liberateGVColumn.Index) + LiberateClicked?.Invoke(lbEntry); + else if (e.ColumnIndex == tagAndDetailsGVColumn.Index) + DetailsClicked?.Invoke(lbEntry); + else if (e.ColumnIndex == descriptionGVColumn.Index) + DescriptionClicked?.Invoke(lbEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); + else if (e.ColumnIndex == coverGVColumn.Index) + CoverClicked?.Invoke(lbEntry); + } + else if (entry is ISeriesEntry sEntry) + { + if (e.ColumnIndex == liberateGVColumn.Index) + { + if (sEntry.Liberate.Expanded) + bindingList.CollapseItem(sEntry); + else + bindingList.ExpandItem(sEntry); + + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); + } + else if (e.ColumnIndex == descriptionGVColumn.Index) + DescriptionClicked?.Invoke(sEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); + else if (e.ColumnIndex == coverGVColumn.Index) + CoverClicked?.Invoke(sEntry); + } + + if (e.ColumnIndex == removeGVColumn.Index) + { + gridEntryDataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit); + RemovableCountChanged?.Invoke(this, EventArgs.Empty); + } + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, $"An error was encountered while processing a user click in the {nameof(ProductsGrid)}"); + } + } + + private IGridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem(rowIndex); + + #endregion + + #region UI display functions + + internal bool RemoveColumnVisible + { + get => removeGVColumn.Visible; + set + { + if (value) + { + foreach (var book in bindingList.AllItems()) + book.Remove = false; + } + + removeGVColumn.DisplayIndex = 0; + removeGVColumn.Frozen = value; + removeGVColumn.Visible = value; + } + } + + internal async Task BindToGridAsync(List dbBooks) + { + var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks); + + var seriesEntries = await SeriesEntry.GetAllSeriesEntriesAsync(dbBooks); + + geList.AddRange(seriesEntries); + //Sort descending by date (default sort property) + var comparer = new RowComparer(); + geList.Sort((a, b) => comparer.Compare(b, a)); + + //Add all children beneath their parent + foreach (var series in seriesEntries) + { + var seriesIndex = geList.IndexOf(series); + foreach (var child in series.Children) + geList.Insert(++seriesIndex, child); + } + + bindingList = new GridEntryBindingList(geList); + bindingList.CollapseAll(); + syncBindingSource.DataSource = bindingList; + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); + } + + internal void UpdateGrid(List dbBooks) + { + //First row that is in view in the DataGridView + var topRow = gridEntryDataGridView.Rows.Cast().FirstOrDefault(r => r.Displayed)?.Index ?? 0; + + #region Add new or update existing grid entries + + //Remove filter prior to adding/updating boooks + string existingFilter = syncBindingSource.Filter; + Filter(null); + + //Add absent entries to grid, or update existing entry + + var allEntries = bindingList.AllItems().BookEntries(); + var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); + var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet(); + + bindingList.RaiseListChangedEvents = false; + foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) + { + var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); + + if (libraryBook.Book.IsProduct()) + { + AddOrUpdateBook(libraryBook, existingEntry); + continue; + } + if (parentedEpisodes.Contains(libraryBook)) + { + //Only try to add or update is this LibraryBook is a know child of a parent + AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); + } + } + bindingList.RaiseListChangedEvents = true; + + //Re-apply filter after adding new/updating existing books to capture any changes + //The Filter call also ensures that the binding list is reset so the DataGridView + //is made aware of all changes that were made while RaiseListChangedEvents was false + Filter(existingFilter); + + #endregion + + // remove deleted from grid. + // note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this + var removedBooks = + bindingList + .AllItems() + .BookEntries() + .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); + + RemoveBooks(removedBooks); + + gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow; + } + + public void RemoveBooks(IEnumerable removedBooks) + { + //Remove books in series from their parents' Children list + foreach (var removed in removedBooks.Where(b => b.Liberate.IsEpisode)) + removed.Parent.RemoveChild(removed); + + //Remove series that have no children + var removedSeries = + bindingList + .AllItems() + .EmptySeries(); + + foreach (var removed in removedBooks.Cast().Concat(removedSeries)) + //no need to re-filter for removed books + bindingList.Remove(removed); + + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); + } + + private void AddOrUpdateBook(LibraryBook book, ILibraryBookEntry existingBookEntry) + { + if (existingBookEntry is null) + // Add the new product to top + bindingList.Insert(0, new LibraryBookEntry(book)); + else + // update existing + existingBookEntry.UpdateLibraryBook(book); + } + + private void AddOrUpdateEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) + { + if (existingEpisodeEntry is null) + { + ILibraryBookEntry episodeEntry; + + var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); + + if (seriesEntry is null) + { + //Series doesn't exist yet, so create and add it + var seriesBook = dbBooks.FindSeriesParent(episodeBook); + + if (seriesBook is null) + { + //This is only possible if the user's db has some malformed + //entries from earlier Libation releases that could not be + //automatically fixed. Log, but don't throw. + Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames()); + return; + } + + seriesEntry = new SeriesEntry(seriesBook, episodeBook); + seriesEntries.Add(seriesEntry); + + episodeEntry = seriesEntry.Children[0]; + seriesEntry.Liberate.Expanded = true; + bindingList.Insert(0, seriesEntry); + } + else + { + //Series exists. Create and add episode child then update the SeriesEntry + episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry); + seriesEntry.Children.Add(episodeEntry); + seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex)); + var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); + seriesEntry.UpdateLibraryBook(seriesBook); + } + + //Series entry must be expanded so its child can + //be placed in the correct position beneath it. + var isExpanded = seriesEntry.Liberate.Expanded; + bindingList.ExpandItem(seriesEntry); + + //Add episode to the grid beneath the parent + int seriesIndex = bindingList.IndexOf(seriesEntry); + int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry); + bindingList.Insert(seriesIndex + 1 + episodeIndex, episodeEntry); + + if (isExpanded) + bindingList.ExpandItem(seriesEntry); + else + bindingList.CollapseItem(seriesEntry); + } + else + existingEpisodeEntry.UpdateLibraryBook(episodeBook); + } + + #endregion + + #region Filter + + public void Filter(string searchString) + { + if (bindingList is null) return; + + int visibleCount = bindingList.Count; + + if (string.IsNullOrEmpty(searchString)) + syncBindingSource.RemoveFilter(); + else + syncBindingSource.Filter = searchString; + + if (visibleCount != bindingList.Count) + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); + } + + #endregion + + #region Column Customizations + + private void ProductsGrid_Load(object sender, EventArgs e) + { + //https://stackoverflow.com/a/4498512/3335599 + if (System.ComponentModel.LicenseManager.UsageMode == System.ComponentModel.LicenseUsageMode.Designtime) return; + + gridEntryDataGridView.ColumnWidthChanged += gridEntryDataGridView_ColumnWidthChanged; + gridEntryDataGridView.ColumnDisplayIndexChanged += gridEntryDataGridView_ColumnDisplayIndexChanged; + + showHideColumnsContextMenuStrip.Items.Add(new ToolStripLabel("Show / Hide Columns")); + showHideColumnsContextMenuStrip.Items.Add(new ToolStripSeparator()); + + //Restore Grid Display Settings + var config = Configuration.Instance; + var gridColumnsVisibilities = config.GridColumnsVisibilities; + var gridColumnsWidths = config.GridColumnsWidths; + var displayIndices = config.GridColumnsDisplayIndices; + + var cmsKiller = new ContextMenuStrip(); + + foreach (DataGridViewColumn column in gridEntryDataGridView.Columns) + { + var itemName = column.DataPropertyName; + var visible = gridColumnsVisibilities.GetValueOrDefault(itemName, true); + + var menuItem = new ToolStripMenuItem(column.HeaderText) + { + Checked = visible, + Tag = itemName + }; + menuItem.Click += HideMenuItem_Click; + showHideColumnsContextMenuStrip.Items.Add(menuItem); + + //Only set column widths for user resizable columns. + //Fixed column widths are set by setGridScale() + if (column.Resizable is not DataGridViewTriState.False) + column.Width = gridColumnsWidths.GetValueOrDefault(itemName, this.DpiScale(column.Width)); + + column.MinimumWidth = 10; + column.HeaderCell.ContextMenuStrip = showHideColumnsContextMenuStrip; + column.Visible = visible; + + //Setting a default ContextMenuStrip will allow the columns to handle the + //Show() event so it is not passed up to the _dataGridView.ContextMenuStrip. + //This allows the ContextMenuStrip to be shown if right-clicking in the gray + //background of _dataGridView but not shown if right-clicking inside cells. + column.ContextMenuStrip = cmsKiller; + } + + //We must set DisplayIndex properties in ascending order + foreach (var itemName in displayIndices.OrderBy(i => i.Value).Select(i => i.Key)) + { + var column = gridEntryDataGridView.Columns + .Cast() + .SingleOrDefault(c => c.DataPropertyName == itemName); + + if (column is null) continue; + + column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index); + } + + //Remove column is always first; + removeGVColumn.DisplayIndex = 0; + removeGVColumn.Visible = false; + removeGVColumn.ValueType = typeof(bool?); + removeGVColumn.FalseValue = false; + removeGVColumn.TrueValue = true; + removeGVColumn.IndeterminateValue = null; + } + + private void HideMenuItem_Click(object sender, EventArgs e) + { + var menuItem = sender as ToolStripMenuItem; + var propertyName = menuItem.Tag as string; + + var column = gridEntryDataGridView.Columns + .Cast() + .FirstOrDefault(c => c.DataPropertyName == propertyName); + + if (column != null) + { + var visible = menuItem.Checked; + menuItem.Checked = !visible; + column.Visible = !visible; + + var config = Configuration.Instance; + + var dictionary = config.GridColumnsVisibilities; + dictionary[propertyName] = column.Visible; + config.GridColumnsVisibilities = dictionary; + } + } + + private void gridEntryDataGridView_ColumnDisplayIndexChanged(object sender, DataGridViewColumnEventArgs e) + { + var config = Configuration.Instance; + + var dictionary = config.GridColumnsDisplayIndices; + dictionary[e.Column.DataPropertyName] = e.Column.DisplayIndex; + config.GridColumnsDisplayIndices = dictionary; + } + + private void gridEntryDataGridView_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e) + { + if (e.ColumnIndex == descriptionGVColumn.Index) + e.ToolTipText = "Click to see full description"; + else if (e.ColumnIndex == coverGVColumn.Index) + e.ToolTipText = "Click to see full size"; + } + + private void gridEntryDataGridView_ColumnWidthChanged(object sender, DataGridViewColumnEventArgs e) + { + var config = Configuration.Instance; + + var dictionary = config.GridColumnsWidths; + dictionary[e.Column.DataPropertyName] = e.Column.Width; + config.GridColumnsWidths = dictionary; + } + + #endregion + + private void gridEntryDataGridView_RowsAdded(object sender, DataGridViewRowsAddedEventArgs e) + { + var grid = sender as DataGridView; + + // hide "Add to Playlist" button for series. + for (int row = 0; row < grid.Rows.Count; row++) + { + if (grid.Rows[row].DataBoundItem is ISeriesEntry) + grid[playlistColumn.Index, row] = GetEmptyCell(); + else + grid[playlistColumn.Index, row] = GetPlaylistButtonCell(); + } + + if (SystemInformation.HighContrast) + { + // Correct high contrast call background colors. + for (int row = 0; row < grid.Rows.Count; row++) + for (int col = 0; col < grid.Columns.Count; col++) + { + if (col > playlistColumn.Index && + grid.Rows[row].DataBoundItem is ILibraryBookEntry b && + b.Parent != null) + grid[col, row].Style.BackColor = Color.FromArgb(32, 32, 32); + else + grid[col, row].Style.BackColor = SystemColors.Control; + } + } + } + + private DataGridViewCell GetPlaylistButtonCell() + { + return new DataGridViewButtonCell + { + Style = new DataGridViewCellStyle + { + Alignment = DataGridViewContentAlignment.MiddleLeft, + Padding = new Padding(8), + WrapMode = DataGridViewTriState.False, + } + }; + } + + private DataGridViewCell GetEmptyCell() => new DataGridViewTextBoxCell + { + Style = new DataGridViewCellStyle + { + Alignment = DataGridViewContentAlignment.MiddleLeft, + SelectionForeColor = Color.Transparent, + ForeColor = Color.Transparent, + WrapMode = DataGridViewTriState.False + } + }; + + private void gridEntryDataGridView_CellClick(object sender, DataGridViewCellEventArgs e) + { + var grid = sender as DataGridView; + + if (e.ColumnIndex == playlistColumn.Index && + grid.Rows[e.RowIndex].DataBoundItem is ILibraryBookEntry book) + { + if (_player.IsInPlaylist(book)) + _player.RemoveFromPlaylist(book); + else + _player.AddToPlaylist(book); + } + } + + private void InvalidateBookEntry(ILibraryBookEntry bookEntry) + { + + for (int row = 0; row < gridEntryDataGridView.Rows.Count; row++) + { + if (gridEntryDataGridView.Rows[row].DataBoundItem is ILibraryBookEntry be && + be.AudibleProductId == bookEntry.AudibleProductId) + { + //bindingList.ResetItem(row); + //gridEntryDataGridView.InvalidateRow(row); + playlistColumn.AutoSizeMode = DataGridViewAutoSizeColumnMode.None; + playlistColumn.AutoSizeMode = DataGridViewAutoSizeColumnMode.AllCells; + return; + } + } + } + } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.resx b/Source/LibationWinForms/GridView/ProductsGrid.resx index 19057fc30..e43aa19c1 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.resx +++ b/Source/LibationWinForms/GridView/ProductsGrid.resx @@ -1,7 +1,7 @@  @@ -58,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - 17, 17 + 513, 31 @@ -636,6 +696,30 @@ True + + True + + + True + + + True + + + True + + + True + + + True + + + True + + + True + 133, 17 diff --git a/Source/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index 2378c0e34..13d3e6a6a 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -2,17 +2,21 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using System.Windows.Forms; using ApplicationServices; using AppScaffolding; using DataLayer; using LibationFileManager; +using LibationUiBase; +using LibationUiBase.ViewModels; +using LibationUiBase.ViewModels.Player; using LibationWinForms.Dialogs; namespace LibationWinForms { - static class Program + static class Program { [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] @@ -84,12 +88,31 @@ static void Main() // global exception handling (ShowAdminAlert) attempts to use logging. only call it after logging has been init'd postLoggingGlobalExceptionHandling(); - var form1 = new Form1(); + RegisterTypes(); + ServiceLocator.AddCommonServicesAndBuild(); + + var form1 = new Form1(); form1.Load += async (_, _) => await form1.InitLibraryAsync(await libraryLoadTask); Application.Run(form1); } - private static void RunInstaller(Configuration config) + private static void RegisterTypes() + { + ServiceLocator.RegisterTransient(); + ServiceLocator.RegisterSingleton(); + ServiceLocator.RegisterTransient(); + + // Register VMs here only. + foreach (var type in Assembly.GetExecutingAssembly().GetExportedTypes()) + { + if (type.IsSubclassOf(typeof(ViewModelBase)) && !type.IsAbstract) + ServiceLocator.RegisterTransient(type); + } + + // Add more types as needed here. + } + + private static void RunInstaller(Configuration config) { // all returns should be preceded by either: // - if config.LibationSettingsAreValid diff --git a/Source/LibationWinForms/WinFormsCanExecuteChanged.cs b/Source/LibationWinForms/WinFormsCanExecuteChanged.cs new file mode 100644 index 000000000..01c44c4d3 --- /dev/null +++ b/Source/LibationWinForms/WinFormsCanExecuteChanged.cs @@ -0,0 +1,15 @@ +using LibationUiBase.ViewModels; +using System; + +namespace LibationWinForms +{ + public class WinFormsCanExecuteChanged : ICanExecuteChanged + { + public event EventHandler Event; + + public void Raise() + { + Event?.Invoke(null, EventArgs.Empty); + } + } +} diff --git a/Source/SystemInformation.cs b/Source/SystemInformation.cs new file mode 100644 index 000000000..1cd4b58a1 --- /dev/null +++ b/Source/SystemInformation.cs @@ -0,0 +1,9 @@ +using System; + +public static class Theme +{ + [DllImport("UXTheme.dll", SetLastError = true, EntryPoint = "#138")] + private static extern bool ShouldSystemUseDarkMode(); + + public static bool IsDarkMode => SystemInformation.HighContrast || ShouldSystemUseDarkMode(); +}