From 9ae1f0399b871cffa5751184d65ea22a10463701 Mon Sep 17 00:00:00 2001 From: MBucari Date: Sun, 19 Mar 2023 20:05:18 -0600 Subject: [PATCH] Add SeriesViewDialog --- Source/ApplicationServices/LibraryCommands.cs | 55 ++++- Source/AudibleUtilities/ApiExtended.cs | 6 +- Source/DataLayer/EfClasses/SeriesBook.cs | 5 +- Source/Libation.sln | 12 ++ .../Controls/LinkLabel.axaml.cs | 8 + .../ViewModels/ProductsDisplayViewModel.cs | 14 +- .../LibationAvalonia/Views/MainWindow.NoUI.cs | 3 + .../Views/ProductsDisplay.axaml.cs | 13 ++ .../Views/SeriesViewDialog.axaml | 32 +++ .../Views/SeriesViewDialog.axaml.cs | 70 +++++++ .../Views/SeriesViewGrid.axaml | 100 +++++++++ .../Views/SeriesViewGrid.axaml.cs | 90 ++++++++ Source/LibationUiBase/BaseUtil.cs | 13 ++ .../LibationUiBase/SeriesView/AyceButton.cs | 133 ++++++++++++ .../LibationUiBase/SeriesView/SeriesButton.cs | 51 +++++ .../LibationUiBase/SeriesView/SeriesEntry.cs | 151 ++++++++++++++ .../LibationUiBase/SeriesView/SeriesOrder.cs | 23 +++ .../SeriesView/WishlistButton.cs | 93 +++++++++ Source/LibationWinForms/Form1._NonUI.cs | 3 + .../GridView/ProductsDisplay.Designer.cs | 1 - .../GridView/ProductsDisplay.cs | 21 +- .../LibationWinForms/GridView/ProductsGrid.cs | 4 +- .../SeriesView/DownloadButtonColumn.cs | 49 +++++ .../SeriesView/SeriesEntryBindingList.cs | 46 +++++ .../SeriesView/SeriesViewDialog.Designer.cs | 62 ++++++ .../SeriesView/SeriesViewDialog.cs | 193 ++++++++++++++++++ .../SeriesView/SeriesViewDialog.resx | 60 ++++++ 27 files changed, 1293 insertions(+), 18 deletions(-) create mode 100644 Source/LibationAvalonia/Views/SeriesViewDialog.axaml create mode 100644 Source/LibationAvalonia/Views/SeriesViewDialog.axaml.cs create mode 100644 Source/LibationAvalonia/Views/SeriesViewGrid.axaml create mode 100644 Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs create mode 100644 Source/LibationUiBase/BaseUtil.cs create mode 100644 Source/LibationUiBase/SeriesView/AyceButton.cs create mode 100644 Source/LibationUiBase/SeriesView/SeriesButton.cs create mode 100644 Source/LibationUiBase/SeriesView/SeriesEntry.cs create mode 100644 Source/LibationUiBase/SeriesView/SeriesOrder.cs create mode 100644 Source/LibationUiBase/SeriesView/WishlistButton.cs create mode 100644 Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs create mode 100644 Source/LibationWinForms/SeriesView/SeriesEntryBindingList.cs create mode 100644 Source/LibationWinForms/SeriesView/SeriesViewDialog.Designer.cs create mode 100644 Source/LibationWinForms/SeriesView/SeriesViewDialog.cs create mode 100644 Source/LibationWinForms/SeriesView/SeriesViewDialog.resx diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 1c101306..0bc9715c 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -171,7 +171,60 @@ namespace ApplicationServices } } - private static async Task> scanAccountsAsync(Func> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions) + public static async Task ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) + { + ArgumentValidator.EnsureNotNull(item, "item"); + ArgumentValidator.EnsureNotNull(accountId, "accountId"); + ArgumentValidator.EnsureNotNull(localeName, "localeName"); + + var importItem = new ImportItem + { + DtoItem = item, + AccountId = accountId, + LocaleName = localeName + }; + + var importItems = new List { importItem }; + var validator = new LibraryValidator(); + var exceptions = validator.Validate(importItems.Select(i => i.DtoItem)); + + if (exceptions?.Any() ?? false) + { + Log.Logger.Error(new AggregateException(exceptions), "Error validating library book. {@DebugInfo}", new { item, accountId, localeName }); + return 0; + } + + using var context = DbContexts.GetContext(); + + var bookImporter = new BookImporter(context); + await Task.Run(() => bookImporter.Import(importItems)); + var book = await Task.Run(() => context.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId)); + + if (book is null) + { + book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId); + context.LibraryBooks.Add(book); + } + else + { + book.AbsentFromLastScan = false; + } + + try + { + int qtyChanged = await Task.Run(() => SaveContext(context)); + if (qtyChanged > 0) + await Task.Run(finalizeLibrarySizeChange); + return qtyChanged; + } + catch (Exception ex) + { + Log.Logger.Error(ex, "Error adding single library book to DB. {@DebugInfo}", new { item, accountId, localeName }); + return 0; + } + } + + private static async Task> scanAccountsAsync(Func> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions) { var tasks = new List>>(); diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index 76c17f15..2c77a971 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -149,7 +149,7 @@ namespace AudibleUtilities foreach (var parent in items.Where(i => i.IsSeriesParent)) { var children = items.Where(i => i.IsEpisodes && i.Relationships.Any(r => r.Asin == parent.Asin)); - setSeries(parent, children); + SetSeries(parent, children); } sw.Stop(); @@ -232,7 +232,7 @@ namespace AudibleUtilities finally { semaphore.Release(); } } - private static void setSeries(Item parent, IEnumerable children) + public static void SetSeries(Item parent, IEnumerable children) { //A series parent will always have exactly 1 Series parent.Series = new[] @@ -267,4 +267,4 @@ namespace AudibleUtilities } #endregion } -} \ No newline at end of file +} diff --git a/Source/DataLayer/EfClasses/SeriesBook.cs b/Source/DataLayer/EfClasses/SeriesBook.cs index fa430a65..825d2515 100644 --- a/Source/DataLayer/EfClasses/SeriesBook.cs +++ b/Source/DataLayer/EfClasses/SeriesBook.cs @@ -8,7 +8,7 @@ namespace DataLayer internal int BookId { get; private set; } public string Order { get; private set; } - public float Index { get; } + public float Index => StringLib.ExtractFirstNumber(Order); public Series Series { get; private set; } public Book Book { get; private set; } @@ -22,8 +22,7 @@ namespace DataLayer Series = series; Book = book; Order = order; - Index = StringLib.ExtractFirstNumber(Order); - } + } public void UpdateOrder(string order) { diff --git a/Source/Libation.sln b/Source/Libation.sln index e7eda5c3..f167db23 100644 --- a/Source/Libation.sln +++ b/Source/Libation.sln @@ -101,6 +101,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation UI", "Libation UI" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation CLI", "Libation CLI", "{47E27674-595D-4F7A-8CFB-127E768E1D1E}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi", "..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj", "{DF6FBE88-A9A0-4CED-86B4-F35A130F349D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi.Common", "..\..\audible api\AudibleApi\AudibleApi.Common\AudibleApi.Common.csproj", "{093E79B6-9A57-46FE-8406-DB16BADAD427}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -219,6 +223,14 @@ Global {E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.ActiveCfg = Release|Any CPU {E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.Build.0 = Release|Any CPU + {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Release|Any CPU.Build.0 = Release|Any CPU + {093E79B6-9A57-46FE-8406-DB16BADAD427}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {093E79B6-9A57-46FE-8406-DB16BADAD427}.Debug|Any CPU.Build.0 = Debug|Any CPU + {093E79B6-9A57-46FE-8406-DB16BADAD427}.Release|Any CPU.ActiveCfg = Release|Any CPU + {093E79B6-9A57-46FE-8406-DB16BADAD427}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs index 30b0d74a..951c6919 100644 --- a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs +++ b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; +using Avalonia.Media; using Avalonia.Styling; using System; @@ -14,7 +15,14 @@ namespace LibationAvalonia.Controls public LinkLabel() { InitializeComponent(); + Tapped += LinkLabel_Tapped; } + + private void LinkLabel_Tapped(object sender, TappedEventArgs e) + { + Foreground = Brushes.Purple; + } + protected override void OnPointerEntered(PointerEventArgs e) { this.Cursor = HandCursor; diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 604b37c3..a6c4bac6 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -114,12 +114,20 @@ namespace LibationAvalonia.ViewModels seriesEntry.Liberate.Expanded = false; geList.Add(seriesEntry); - geList.AddRange(seriesEntry.Children); } //Create the filtered-in list before adding entries to avoid a refresh FilteredInGridEntries = QueryResults(geList, FilterString); SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded)); + + //Add all children beneath their parent + foreach (var series in SOURCE.OfType().ToList()) + { + var seriesIndex = SOURCE.IndexOf(series); + foreach (var child in series.Children) + SOURCE.Insert(++seriesIndex, child); + } + GridEntries.CollectionChanged += GridEntries_CollectionChanged; GridEntries_CollectionChanged(); } @@ -253,13 +261,15 @@ namespace LibationAvalonia.ViewModels //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); } //Add episode to the grid beneath the parent int seriesIndex = SOURCE.IndexOf(seriesEntry); - SOURCE.Insert(seriesIndex + 1, episodeEntry); + int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry); + SOURCE.Insert(seriesIndex + 1 + episodeIndex, episodeEntry); } else existingEpisodeEntry.UpdateLibraryBook(episodeBook); diff --git a/Source/LibationAvalonia/Views/MainWindow.NoUI.cs b/Source/LibationAvalonia/Views/MainWindow.NoUI.cs index d04623e5..acb239ad 100644 --- a/Source/LibationAvalonia/Views/MainWindow.NoUI.cs +++ b/Source/LibationAvalonia/Views/MainWindow.NoUI.cs @@ -1,4 +1,5 @@ using LibationFileManager; +using LibationUiBase; using System; using System.IO; using System.Linq; @@ -21,6 +22,8 @@ namespace LibationAvalonia.Views App.OpenAsset("img-coverart-prod-unavailable_500x500.jpg").CopyTo(ms3); PictureStorage.SetDefaultImage(PictureSize._500x500, ms3.ToArray()); PictureStorage.SetDefaultImage(PictureSize.Native, ms3.ToArray()); + + BaseUtil.SetLoadImageDelegate(AvaloniaUtils.TryLoadImageOrDefault); } } } diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 1d107bf2..d7d84c51 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -215,6 +215,19 @@ namespace LibationAvalonia.Views bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window); } + #endregion + #region View All Series + + if (entry.Book.SeriesLink.Any()) + { + var header = entry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series"; + var viewSeriesMenuItem = new MenuItem { Header = header }; + + args.ContextMenuItems.Add(viewSeriesMenuItem); + + viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show(); + } + #endregion } else diff --git a/Source/LibationAvalonia/Views/SeriesViewDialog.axaml b/Source/LibationAvalonia/Views/SeriesViewDialog.axaml new file mode 100644 index 00000000..370e397d --- /dev/null +++ b/Source/LibationAvalonia/Views/SeriesViewDialog.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Views/SeriesViewDialog.axaml.cs b/Source/LibationAvalonia/Views/SeriesViewDialog.axaml.cs new file mode 100644 index 00000000..f80e0991 --- /dev/null +++ b/Source/LibationAvalonia/Views/SeriesViewDialog.axaml.cs @@ -0,0 +1,70 @@ +using AudibleApi.Common; +using AudibleApi; +using Avalonia.Controls; +using DataLayer; +using Dinah.Core; +using FileLiberator; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Collections; +using LibationAvalonia.Dialogs; +using LibationUiBase.SeriesView; +using System; +using Avalonia.Media; + +namespace LibationAvalonia.Views +{ + public partial class SeriesViewDialog : DialogWindow + { + private readonly LibraryBook LibraryBook; + public AvaloniaList TabItems { get; } = new(); + public SeriesViewDialog() + { + InitializeComponent(); + DataContext = this; + + if (Design.IsDesignMode) + { + TabItems.Add(new TabItem { Header = "This is a Header", FontSize = 14, Content = new TextBlock { Text = "Some Text" } }); + } + else + { + Loaded += SeriesViewDialog_Loaded; + } + } + + public SeriesViewDialog(LibraryBook libraryBook) : this() + { + LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, "libraryBook"); + } + + private async void SeriesViewDialog_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + try + { + var seriesEntries = await SeriesItem.GetAllSeriesItemsAsync(LibraryBook); + + foreach (var series in seriesEntries.Keys) + { + TabItems.Add(new TabItem + { + Header = series.Title, + FontSize = 14, + Content = new SeriesViewGrid(LibraryBook, series, seriesEntries[series]) + }); + } + + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error loading searies info"); + + TabItems.Add(new TabItem + { + Header = "ERROR", + Content = new TextBlock { Text = "ERROR LOADING SERIES INFO\r\n\r\n" + ex.Message, Foreground = Brushes.Red } + }); + } + } + } +} diff --git a/Source/LibationAvalonia/Views/SeriesViewGrid.axaml b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml new file mode 100644 index 00000000..0092955c --- /dev/null +++ b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs new file mode 100644 index 00000000..857a7632 --- /dev/null +++ b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs @@ -0,0 +1,90 @@ +using ApplicationServices; +using AudibleApi.Common; +using AudibleUtilities; +using Avalonia.Collections; +using Avalonia.Controls; +using DataLayer; +using Dinah.Core; +using LibationAvalonia.Controls; +using LibationAvalonia.Dialogs; +using LibationFileManager; +using LibationUiBase.SeriesView; +using System.Collections.Generic; +using System.Linq; + +namespace LibationAvalonia.Views +{ + public partial class SeriesViewGrid : UserControl + { + private ImageDisplayDialog imageDisplayDialog; + private readonly LibraryBook LibraryBook; + + public AvaloniaList SeriesEntries { get; } = new(); + + public SeriesViewGrid() + { + InitializeComponent(); + DataContext = this; + } + + public SeriesViewGrid(LibraryBook libraryBook, Item series, List entries) : this() + { + LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)); + ArgumentValidator.EnsureNotNull(series, nameof(series)); + ArgumentValidator.EnsureNotNull(entries, nameof(entries)); + + SeriesEntries.AddRange(entries.OrderBy(s => s.Order)); + } + + public async void Availability_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + if (sender is Button button && button.DataContext is SeriesItem sentry && sentry.Button.HasButtonAction) + { + await sentry.Button.PerformClickAsync(LibraryBook); + } + } + public void Title_Click(object sender, Avalonia.Input.TappedEventArgs args) + { + if (sender is not LinkLabel label || label.DataContext is not SeriesItem sentry) + return; + + sentry.ViewOnAudible(LibraryBook.Book.Locale); + } + + public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) + { + if (sender is not Image tblock || tblock.DataContext is not SeriesItem sentry) + return; + + Item libraryBook = sentry.Item; + + if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible) + { + imageDisplayDialog = new ImageDisplayDialog(); + } + + var picDef = new PictureDefinition(libraryBook.PictureLarge ?? libraryBook.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 = $"{libraryBook.Title} - Cover"; + + imageDisplayDialog.Title = windowTitle; + imageDisplayDialog.SetCoverBytes(initialImageBts); + + if (!isDefault) + PictureStorage.PictureCached -= PictureCached; + + if (!imageDisplayDialog.IsVisible) + imageDisplayDialog.Show(); + } + } +} diff --git a/Source/LibationUiBase/BaseUtil.cs b/Source/LibationUiBase/BaseUtil.cs new file mode 100644 index 00000000..4bf512d4 --- /dev/null +++ b/Source/LibationUiBase/BaseUtil.cs @@ -0,0 +1,13 @@ +using LibationFileManager; +using System; + +namespace LibationUiBase +{ + public static class BaseUtil + { + /// A delegate that loads image bytes into the the UI framework's image format. + public static Func LoadImage { get; private set; } + public static void SetLoadImageDelegate(Func tryLoadImage) + => LoadImage = tryLoadImage; + } +} diff --git a/Source/LibationUiBase/SeriesView/AyceButton.cs b/Source/LibationUiBase/SeriesView/AyceButton.cs new file mode 100644 index 00000000..07d4318c --- /dev/null +++ b/Source/LibationUiBase/SeriesView/AyceButton.cs @@ -0,0 +1,133 @@ +using ApplicationServices; +using AudibleApi; +using AudibleApi.Common; +using DataLayer; +using FileLiberator; +using System; +using System.Linq; +using Dinah.Core; +using System.Threading.Tasks; + +namespace LibationUiBase.SeriesView +{ + internal class AyceButton : SeriesButton + { + //Making this event and field static prevents concurrent additions to the library. + //Search engine indexer does not support concurrent re-indexing. + private static event EventHandler ButtonEnabled; + private static bool globalEnabled = true; + + public override bool HasButtonAction => true; + public override string DisplayText + => InLibrary ? "Remove\r\nFrom\r\nLibrary" + : "FREE\r\n\r\nAdd to\r\nLibrary"; + + public override bool Enabled + { + get => globalEnabled; + protected set + { + if (globalEnabled != value) + { + globalEnabled = value; + ButtonEnabled?.Invoke(null, EventArgs.Empty); + } + } + } + + internal AyceButton(Item item, bool inLibrary) : base(item, inLibrary) + { + ButtonEnabled += DownloadButton_ButtonEnabled; + } + + public override async Task PerformClickAsync(LibraryBook accountBook) + { + if (!Enabled) return; + + Enabled = false; + + try + { + if (InLibrary) + await RemoveFromLibraryAsync(accountBook); + else + await AddToLibraryAsync(accountBook); + } + catch(Exception ex) + { + var addRemove = InLibrary ? "remove" : "add"; + var toFrom = InLibrary ? "from" : "to"; + + Serilog.Log.Logger.Error(ex, $"Failed to {addRemove} {{book}} {toFrom} library", new { Item.ProductId, Item.TitleWithSubtitle }); + } + finally { Enabled = true; } + + } + + private async Task RemoveFromLibraryAsync(LibraryBook accountBook) + { + Api api = await accountBook.GetApiAsync(); + + if (await api.RemoveItemFromLibraryAsync(Item.ProductId)) + { + using var context = DbContexts.GetContext(); + var lb = context.GetLibraryBook_Flat_NoTracking(Item.ProductId); + int result = await Task.Run((new[] { lb }).PermanentlyDeleteBooks); + InLibrary = result == 0; + } + } + + private async Task AddToLibraryAsync(LibraryBook accountBook) + { + Api api = await accountBook.GetApiAsync(); + + if (!await api.AddItemToLibraryAsync(Item.ProductId)) return; + + Item item = null; + + for (int tryCount = 0; tryCount < 5 && item is null; tryCount++) + { + //Wait a half second to allow the server time to update + await Task.Delay(500); + item = await api.GetLibraryBookAsync(Item.ProductId, LibraryOptions.ResponseGroupOptions.ALL_OPTIONS); + } + + if (item is null) return; + + if (item.IsEpisodes) + { + var seriesParent = DbContexts.GetLibrary_Flat_NoTracking(includeParents: true) + .Select(lb => lb.Book) + .FirstOrDefault(b => b.IsEpisodeParent() && b.AudibleProductId.In(item.Relationships.Select((Relationship r) => r.Asin))); + + if (seriesParent is null) return; + + item.Series = new[] + { + new AudibleApi.Common.Series + { + Asin = seriesParent.AudibleProductId, + Sequence = item.Relationships.FirstOrDefault(r => r.Asin == seriesParent.AudibleProductId)?.Sort?.ToString() ?? "0", + Title = seriesParent.Title + } + }; + } + + InLibrary = await LibraryCommands.ImportSingleToDbAsync(item, accountBook.Account, accountBook.Book.Locale) != 0; + } + + private void DownloadButton_ButtonEnabled(object sender, EventArgs e) + => OnPropertyChanged(nameof(Enabled)); + + public override int CompareTo(object ob) + { + if (ob is not AyceButton other) return 1; + return other.InLibrary.CompareTo(InLibrary); + } + + ~AyceButton() + { + ButtonEnabled -= DownloadButton_ButtonEnabled; + } + } +} diff --git a/Source/LibationUiBase/SeriesView/SeriesButton.cs b/Source/LibationUiBase/SeriesView/SeriesButton.cs new file mode 100644 index 00000000..3156c036 --- /dev/null +++ b/Source/LibationUiBase/SeriesView/SeriesButton.cs @@ -0,0 +1,51 @@ +using AudibleApi.Common; +using DataLayer; +using Dinah.Core.Threading; +using System; +using System.ComponentModel; +using System.Threading.Tasks; + +namespace LibationUiBase.SeriesView +{ + /// + /// base view model for the Series Viewer 'Availability' button column + /// + public abstract class SeriesButton : SynchronizeInvoker, IComparable, INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + private bool inLibrary; + + protected Item Item { get; } + public abstract string DisplayText { get; } + public abstract bool HasButtonAction { get; } + public abstract bool Enabled { get; protected set; } + public bool InLibrary + { + get => inLibrary; + protected set + { + if (inLibrary != value) + { + inLibrary = value; + OnPropertyChanged(nameof(InLibrary)); + OnPropertyChanged(nameof(DisplayText)); + } + } + } + + protected SeriesButton(Item item, bool inLibrary) + { + Item = item; + this.inLibrary = inLibrary; + } + + public abstract Task PerformClickAsync(LibraryBook accountBook); + + protected void OnPropertyChanged(string propertyName) + => Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName))); + + public override string ToString() => DisplayText; + + public abstract int CompareTo(object ob); + } +} diff --git a/Source/LibationUiBase/SeriesView/SeriesEntry.cs b/Source/LibationUiBase/SeriesView/SeriesEntry.cs new file mode 100644 index 00000000..44b451e4 --- /dev/null +++ b/Source/LibationUiBase/SeriesView/SeriesEntry.cs @@ -0,0 +1,151 @@ +using ApplicationServices; +using AudibleApi; +using AudibleApi.Common; +using AudibleUtilities; +using DataLayer; +using Dinah.Core; +using Dinah.Core.Threading; +using FileLiberator; +using LibationFileManager; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace LibationUiBase.SeriesView +{ + public class SeriesItem : SynchronizeInvoker, INotifyPropertyChanged + { + public object Cover { get; private set; } + public SeriesOrder Order { get; } + public string Title => Item.TitleWithSubtitle; + public SeriesButton Button { get; } + public Item Item { get; } + + public event PropertyChangedEventHandler PropertyChanged; + + private SeriesItem(Item item, string order, bool inLibrary, bool inWishList) + { + Item = item; + Order = new SeriesOrder(order); + Button = Item.Plans.Any(p => p.IsAyce) ? new AyceButton(item, inLibrary) : new WishlistButton(item, inLibrary, inWishList); + LoadCover(item.PictureId); + Button.PropertyChanged += DownloadButton_PropertyChanged; + } + + public void ViewOnAudible(string localeString) + { + var locale = Localization.Get(localeString); + var link = $"https://www.audible.{locale.TopDomain}/pd/{Item.ProductId}"; + Go.To.Url(link); + } + + private void DownloadButton_PropertyChanged(object sender, PropertyChangedEventArgs e) + => OnPropertyChanged(nameof(Button)); + + private void OnPropertyChanged(string propertyName) + => Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName))); + + private void LoadCover(string pictureId) + { + var (isDefault, picture) = PictureStorage.GetPicture(new PictureDefinition(pictureId, PictureSize._80x80)); + if (isDefault) + { + PictureStorage.PictureCached += PictureStorage_PictureCached; + } + Cover = BaseUtil.LoadImage(picture, PictureSize._80x80); + } + + private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) + { + if (e?.Definition.PictureId != null && Item?.PictureId != null) + { + byte[] picture = e.Picture; + if ((picture == null || picture.Length != 0) && e.Definition.PictureId == Item.PictureId) + { + Cover = BaseUtil.LoadImage(e.Picture, PictureSize._80x80); + PictureStorage.PictureCached -= PictureStorage_PictureCached; + OnPropertyChanged(nameof(Cover)); + } + } + } + + public static async Task>> GetAllSeriesItemsAsync(LibraryBook libraryBook) + { + var api = await libraryBook.GetApiAsync(); + + //Get Item for each series that this book belong to + var seriesItemsTask = api.GetCatalogProductsAsync(libraryBook.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId), CatalogOptions.ResponseGroupOptions.Media | CatalogOptions.ResponseGroupOptions.Relationships); + + using var semaphore = new SemaphoreSlim(10); + + //Start getting the wishlist in the background + var wishlistTask = api.GetWishListProductsAsync( + new WishListOptions + { + PageNumber = 0, + NumberOfResultPerPage = 50, + ResponseGroups = WishListOptions.ResponseGroupOptions.None + }, + numItemsPerRequest: 50, + semaphore); + + var items = new Dictionary>(); + + //Get all children of all series + foreach (var series in await seriesItemsTask) + { + //Books that are part of series have RelationshipType.Series + //Podcast episodes have RelationshipType.Episode + var childrenAsins = series.Relationships + .Where(r => r.RelationshipType is RelationshipType.Series or RelationshipType.Episode && r.RelationshipToProduct is RelationshipToProduct.Child) + .Select(r => r.Asin) + .ToList(); + + if (childrenAsins.Count > 0) + { + var children = await api.GetCatalogProductsAsync(childrenAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS, 50, semaphore); + + //If the price is null, this item is not available to the user + var childrenWithPrices = children.Where(p => p.Price != null).ToList(); + + if (childrenWithPrices.Count > 0) + items[series] = childrenWithPrices; + } + } + + //Await the wishlist asins + var wishlistAsins = (await wishlistTask).Select(w => w.Asin).ToHashSet(); + + var fullLib = DbContexts.GetLibrary_Flat_NoTracking(); + var seriesEntries = new Dictionary>(); + + //Create a SeriesItem liste for each series. + foreach (var series in items.Keys) + { + ApiExtended.SetSeries(series, items[series]); + + seriesEntries[series] = new List(); + + foreach (var item in items[series].Where(i => !string.IsNullOrEmpty(i.PictureId))) + { + var order = item.Series.Single(s => s.Asin == series.Asin).Sequence; + //Match the account/book in the database + var inLibrary = fullLib.Any(lb => lb.Account == libraryBook.Account && lb.Book.AudibleProductId == item.ProductId && !lb.AbsentFromLastScan); + var inWishList = wishlistAsins.Contains(item.Asin); + + seriesEntries[series].Add(new SeriesItem(item, order, inLibrary, inWishList)); + } + } + + return seriesEntries; + } + + ~SeriesItem() + { + PictureStorage.PictureCached -= PictureStorage_PictureCached; + Button.PropertyChanged -= DownloadButton_PropertyChanged; + } + } +} diff --git a/Source/LibationUiBase/SeriesView/SeriesOrder.cs b/Source/LibationUiBase/SeriesView/SeriesOrder.cs new file mode 100644 index 00000000..ad09dc5f --- /dev/null +++ b/Source/LibationUiBase/SeriesView/SeriesOrder.cs @@ -0,0 +1,23 @@ +using System; + +namespace LibationUiBase.SeriesView +{ + public class SeriesOrder : IComparable + { + public float Order { get; } + public string OrderString { get; } + + public SeriesOrder(string orderString) + { + OrderString = orderString; + Order = float.TryParse(orderString, out var o) ? o : -1f; + } + public override string ToString() => OrderString; + + public int CompareTo(object obj) + { + if (obj is not SeriesOrder other) return 1; + return Order.CompareTo(other.Order); + } + } +} diff --git a/Source/LibationUiBase/SeriesView/WishlistButton.cs b/Source/LibationUiBase/SeriesView/WishlistButton.cs new file mode 100644 index 00000000..fcf4bcb8 --- /dev/null +++ b/Source/LibationUiBase/SeriesView/WishlistButton.cs @@ -0,0 +1,93 @@ +using AudibleApi; +using AudibleApi.Common; +using DataLayer; +using FileLiberator; +using System; +using System.Threading.Tasks; + +namespace LibationUiBase.SeriesView +{ + internal class WishlistButton : SeriesButton + { + private bool instanceEnabled = true; + + private bool inWishList; + + public override bool HasButtonAction => !InLibrary; + public override string DisplayText + => InLibrary ? "Already\r\nOwned" + : InWishList ? "Remove\r\nFrom\r\nWishlist" + : "Add to\r\nWishlist"; + + public override bool Enabled + { + get => instanceEnabled; + protected set + { + if (instanceEnabled != value) + { + instanceEnabled = value; + OnPropertyChanged(nameof(Enabled)); + } + } + } + + private bool InWishList + { + get => inWishList; + set + { + if (inWishList != value) + { + inWishList = value; + OnPropertyChanged(nameof(InWishList)); + OnPropertyChanged(nameof(DisplayText)); + } + } + } + + internal WishlistButton(Item item, bool inLibrary, bool inWishList) : base(item, inLibrary) + { + this.inWishList = inWishList; + } + + public override async Task PerformClickAsync(LibraryBook accountBook) + { + if (!Enabled || !HasButtonAction) return; + + Enabled = false; + + try + { + Api api = await accountBook.GetApiAsync(); + + if (InWishList) + { + await api.DeleteFromWishListAsync(Item.Asin); + InWishList = false; + } + else + { + await api.AddToWishListAsync(Item.Asin); + InWishList = true; + } + } + catch (Exception ex) + { + var addRemove = InWishList ? "remove" : "add"; + var toFrom = InWishList ? "from" : "to"; + + Serilog.Log.Logger.Error(ex, $"Failed to {addRemove} {{book}} {toFrom} wish list", new { Item.ProductId, Item.TitleWithSubtitle }); + } + finally { Enabled = true; } + } + + public override int CompareTo(object ob) + { + if (ob is not WishlistButton other) return -1; + + int libcmp = other.InLibrary.CompareTo(InLibrary); + return (libcmp == 0) ? other.InWishList.CompareTo(InWishList) : libcmp; + } + } +} diff --git a/Source/LibationWinForms/Form1._NonUI.cs b/Source/LibationWinForms/Form1._NonUI.cs index e17a81a3..21ddc9f3 100644 --- a/Source/LibationWinForms/Form1._NonUI.cs +++ b/Source/LibationWinForms/Form1._NonUI.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using ApplicationServices; using Dinah.Core.WindowsDesktop.Drawing; using LibationFileManager; +using LibationUiBase; namespace LibationWinForms { @@ -20,6 +21,8 @@ namespace LibationWinForms PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format)); PictureStorage.SetDefaultImage(PictureSize.Native, Properties.Resources.default_cover_500x500.ToBytes(format)); + BaseUtil.SetLoadImageDelegate(WinFormsUtil.TryLoadImageOrDefault); + // wire-up event to automatically download after scan. // winforms only. this should NOT be allowed in cli updateCountsBw.RunWorkerCompleted += (object sender, System.ComponentModel.RunWorkerCompletedEventArgs e) => diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs index 879d2f62..248a5afb 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs @@ -41,7 +41,6 @@ productsGrid.TabIndex = 0; productsGrid.VisibleCountChanged += productsGrid_VisibleCountChanged; productsGrid.LiberateClicked += productsGrid_LiberateClicked; - productsGrid.ConvertToMp3Clicked += productsGrid_ConvertToMp3Clicked; productsGrid.CoverClicked += productsGrid_CoverClicked; productsGrid.DetailsClicked += productsGrid_DetailsClicked; productsGrid.DescriptionClicked += productsGrid_DescriptionClicked; diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index c4659d81..2ad49a05 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -5,6 +5,7 @@ using FileLiberator; using LibationFileManager; using LibationUiBase.GridView; using LibationWinForms.Dialogs; +using LibationWinForms.SeriesView; using System; using System.Collections.Generic; using System.Drawing; @@ -218,6 +219,20 @@ namespace LibationWinForms.GridView bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry.LibraryBook).ShowDialog(this); } + #endregion + #region View All Series + + if (entry.Book.SeriesLink.Any()) + { + var header = entry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series"; + + var viewSeriesMenuItem = new ToolStripMenuItem { Text = header }; + + ctxMenu.Items.Add(viewSeriesMenuItem); + + viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show(); + } + #endregion } @@ -333,12 +348,6 @@ namespace LibationWinForms.GridView LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook); } - private void productsGrid_ConvertToMp3Clicked(ILibraryBookEntry liveGridEntry) - { - if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error) - ConvertToMp3Clicked?.Invoke(this, liveGridEntry.LibraryBook); - } - private void productsGrid_RemovableCountChanged(object sender, EventArgs e) { RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is true)); diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 0c09e8fc..54a82439 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -21,7 +21,6 @@ namespace LibationWinForms.GridView /// Number of visible rows has changed public event EventHandler VisibleCountChanged; public event LibraryBookEntryClickedEventHandler LiberateClicked; - public event LibraryBookEntryClickedEventHandler ConvertToMp3Clicked; public event GridEntryClickedEventHandler CoverClicked; public event LibraryBookEntryClickedEventHandler DetailsClicked; public event GridEntryRectangleClickedEventHandler DescriptionClicked; @@ -308,7 +307,8 @@ namespace LibationWinForms.GridView //Add episode to the grid beneath the parent int seriesIndex = bindingList.IndexOf(seriesEntry); - bindingList.Insert(seriesIndex + 1, episodeEntry); + int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry); + bindingList.Insert(seriesIndex + 1 + episodeIndex, episodeEntry); if (seriesEntry.Liberate.Expanded) bindingList.ExpandItem(seriesEntry); diff --git a/Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs b/Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs new file mode 100644 index 00000000..7936c895 --- /dev/null +++ b/Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs @@ -0,0 +1,49 @@ +using System.Drawing; +using System.Windows.Forms; +using System.Windows.Forms.VisualStyles; +using LibationUiBase.SeriesView; + +namespace LibationWinForms.SeriesView +{ + public class DownloadButtonColumn : DataGridViewButtonColumn + { + public DownloadButtonColumn() + { + CellTemplate = new DownloadButtonColumnCell(); + CellTemplate.Style.WrapMode = DataGridViewTriState.True; + } + } + internal class DownloadButtonColumnCell : DataGridViewButtonCell + { + protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + { + if (value is SeriesButton sentry) + { + string cellValue = sentry.DisplayText; + if (!sentry.Enabled) + { + //Draw disabled button + Rectangle buttonArea = cellBounds; + Rectangle buttonAdjustment = BorderWidths(advancedBorderStyle); + buttonArea.X += buttonAdjustment.X; + buttonArea.Y += buttonAdjustment.Y; + buttonArea.Height -= buttonAdjustment.Height; + buttonArea.Width -= buttonAdjustment.Width; + ButtonRenderer.DrawButton(graphics, buttonArea, cellValue, cellStyle.Font, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.WordBreak, focused: false, PushButtonState.Disabled); + + } + else if (sentry.HasButtonAction) + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, cellValue, cellValue, errorText, cellStyle, advancedBorderStyle, paintParts); + else + { + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); + TextRenderer.DrawText(graphics, cellValue, cellStyle.Font, cellBounds, cellStyle.ForeColor); + } + } + else + { + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); + } + } + } +} diff --git a/Source/LibationWinForms/SeriesView/SeriesEntryBindingList.cs b/Source/LibationWinForms/SeriesView/SeriesEntryBindingList.cs new file mode 100644 index 00000000..0c07e8e7 --- /dev/null +++ b/Source/LibationWinForms/SeriesView/SeriesEntryBindingList.cs @@ -0,0 +1,46 @@ +using LibationUiBase.SeriesView; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace LibationWinForms.SeriesView +{ + internal class SeriesEntryBindingList : BindingList + { + private PropertyDescriptor _propertyDescriptor; + + private ListSortDirection _listSortDirection; + + private bool _isSortedCore; + + protected override PropertyDescriptor SortPropertyCore => _propertyDescriptor; + + protected override ListSortDirection SortDirectionCore => _listSortDirection; + + protected override bool IsSortedCore => _isSortedCore; + + protected override bool SupportsSortingCore => true; + + public SeriesEntryBindingList() : base(new List()) { } + public SeriesEntryBindingList(IEnumerable entries) : base(entries.ToList()) { } + + protected override void ApplySortCore(PropertyDescriptor prop, ListSortDirection direction) + { + var itemsList = (List)base.Items; + + var sorted + = (direction == ListSortDirection.Ascending) + ? itemsList.OrderBy(prop.GetValue).ToList() + : itemsList.OrderByDescending(prop.GetValue).ToList(); + + itemsList.Clear(); + itemsList.AddRange(sorted); + + _propertyDescriptor = prop; + _listSortDirection = direction; + _isSortedCore = true; + + OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); + } + } +} diff --git a/Source/LibationWinForms/SeriesView/SeriesViewDialog.Designer.cs b/Source/LibationWinForms/SeriesView/SeriesViewDialog.Designer.cs new file mode 100644 index 00000000..21a2c38a --- /dev/null +++ b/Source/LibationWinForms/SeriesView/SeriesViewDialog.Designer.cs @@ -0,0 +1,62 @@ +using System.Windows.Forms; + +namespace LibationWinForms.SeriesView +{ + partial class SeriesViewDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + tabControl1 = new TabControl(); + SuspendLayout(); + // + // tabControl1 + // + tabControl1.Dock = DockStyle.Fill; + tabControl1.Location = new System.Drawing.Point(0, 0); + tabControl1.Name = "tabControl1"; + tabControl1.SelectedIndex = 0; + tabControl1.Size = new System.Drawing.Size(800, 450); + tabControl1.TabIndex = 0; + // + // SeriesViewDialog + // + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new System.Drawing.Size(800, 450); + Controls.Add(tabControl1); + FormBorderStyle = FormBorderStyle.SizableToolWindow; + Name = "SeriesViewDialog"; + StartPosition = FormStartPosition.CenterParent; + Text = "View All Items in Series"; + ResumeLayout(false); + } + + private TabControl tabControl1; + + #endregion + } +} \ No newline at end of file diff --git a/Source/LibationWinForms/SeriesView/SeriesViewDialog.cs b/Source/LibationWinForms/SeriesView/SeriesViewDialog.cs new file mode 100644 index 00000000..aac9a704 --- /dev/null +++ b/Source/LibationWinForms/SeriesView/SeriesViewDialog.cs @@ -0,0 +1,193 @@ +using AudibleApi.Common; +using DataLayer; +using Dinah.Core; +using System.ComponentModel; +using System.Windows.Forms; +using System; +using Dinah.Core.WindowsDesktop.Forms; +using LibationWinForms.GridView; +using LibationFileManager; +using LibationUiBase.SeriesView; +using System.Drawing; + +namespace LibationWinForms.SeriesView +{ + public partial class SeriesViewDialog : Form + { + private readonly LibraryBook LibraryBook; + + public SeriesViewDialog() + { + InitializeComponent(); + this.RestoreSizeAndLocation(Configuration.Instance); + this.SetLibationIcon(); + + Load += SeriesViewDialog_Load; + FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); + } + + public SeriesViewDialog(LibraryBook libraryBook) : this() + { + LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, "libraryBook"); + } + + private async void SeriesViewDialog_Load(object sender, EventArgs e) + { + try + { + var seriesEntries = await SeriesItem.GetAllSeriesItemsAsync(LibraryBook); + + //Create a DataGridView for each series and add all children of that series to it. + foreach (var series in seriesEntries.Keys) + { + var dgv = createNewSeriesGrid(); + dgv.CellContentClick += Dgv_CellContentClick; + dgv.DataSource = new SeriesEntryBindingList(seriesEntries[series]); + dgv.BindingContextChanged += (_, _) => dgv.Sort(dgv.Columns["Order"], ListSortDirection.Ascending); + + var tab = new TabPage { Text = series.Title }; + tab.Controls.Add(dgv); + tab.VisibleChanged += (_, _) => dgv.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.AllCells); + tabControl1.Controls.Add(tab); + } + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error loading searies info"); + + var tab = new TabPage { Text = "ERROR" }; + tab.Controls.Add(new Label { Text = "ERROR LOADING SERIES INFO\r\n\r\n" + ex.Message, ForeColor = Color.Red, Dock = DockStyle.Fill }); + tabControl1.Controls.Add(tab); + } + } + + private ImageDisplay imageDisplay; + + private async void Dgv_CellContentClick(object sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex < 0) return; + + var dgv = (DataGridView)sender; + var sentry = dgv.GetBoundItem(e.RowIndex); + + if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Cover)) + { + coverClicked(sentry.Item); + return; + } + else if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Title)) + { + sentry.ViewOnAudible(LibraryBook.Book.Locale); + return; + } + else if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Button) && sentry.Button.HasButtonAction) + { + await sentry.Button.PerformClickAsync(LibraryBook); + } + } + + private void coverClicked(Item libraryBook) + { + var picDef = new PictureDefinition(libraryBook.PictureLarge ?? libraryBook.PictureId, PictureSize.Native); + + void PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == picDef.PictureId) + imageDisplay.SetCoverArt(e.Picture); + + PictureStorage.PictureCached -= PictureCached; + } + + PictureStorage.PictureCached += PictureCached; + (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); + + var windowTitle = $"{libraryBook.Title} - Cover"; + + if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) + { + imageDisplay = new ImageDisplay(); + imageDisplay.RestoreSizeAndLocation(Configuration.Instance); + imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); + } + + imageDisplay.Text = windowTitle; + imageDisplay.SetCoverArt(initialImageBts); + if (!isDefault) + PictureStorage.PictureCached -= PictureCached; + + if (!imageDisplay.Visible) + imageDisplay.Show(); + } + + private static DataGridView createNewSeriesGrid() + { + var dgv = new DataGridView + { + Dock = DockStyle.Fill, + RowHeadersVisible = false, + ReadOnly = false, + ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + AllowUserToResizeRows = false, + AutoGenerateColumns = false + }; + + dgv.RowTemplate.Height = 80; + + dgv.Columns.Add(new DataGridViewImageColumn + { + DataPropertyName = nameof(SeriesItem.Cover), + HeaderText = "Cover", + Name = "Cover", + ReadOnly = true, + Resizable = DataGridViewTriState.False, + Width = 80 + }); + dgv.Columns.Add(new DataGridViewTextBoxColumn + { + DataPropertyName = nameof(SeriesItem.Order), + HeaderText = "Series\r\nOrder", + Name = "Order", + ReadOnly = true, + SortMode = DataGridViewColumnSortMode.Automatic, + Width = 50 + }); + dgv.Columns.Add(new DownloadButtonColumn + { + DataPropertyName = nameof(SeriesItem.Button), + HeaderText = "Availability", + Name = "DownloadButton", + ReadOnly = true, + SortMode = DataGridViewColumnSortMode.Automatic, + Width = 50 + }); + dgv.Columns.Add(new DataGridViewLinkColumn + { + DataPropertyName = nameof(SeriesItem.Title), + HeaderText = "Title", + Name = "Title", + ReadOnly = true, + TrackVisitedState = true, + SortMode = DataGridViewColumnSortMode.Automatic, + Width = 200, + }); + + dgv.CellToolTipTextNeeded += Dgv_CellToolTipTextNeeded; + + return dgv; + } + + private static void Dgv_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e) + { + if (sender is not DataGridView dgv || e.ColumnIndex < 0) return; + + e.ToolTipText = dgv.Columns[e.ColumnIndex].DataPropertyName switch + { + nameof(SeriesItem.Cover) => "Click to see full size", + nameof(SeriesItem.Title) => "Open Audible product page", + _ => string.Empty + }; + } + } +} diff --git a/Source/LibationWinForms/SeriesView/SeriesViewDialog.resx b/Source/LibationWinForms/SeriesView/SeriesViewDialog.resx new file mode 100644 index 00000000..f298a7be --- /dev/null +++ b/Source/LibationWinForms/SeriesView/SeriesViewDialog.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file