From 6665dd43614b5a3a9a9caf912c8de676c9d168b6 Mon Sep 17 00:00:00 2001 From: David Federman Date: Fri, 20 Mar 2026 22:49:51 -0700 Subject: [PATCH] Add secondary text to cards --- src/JellyBox/Models/Card.cs | 4 ++ src/JellyBox/Models/CardFactory.cs | 60 ++++++++++++++++++- src/JellyBox/Resources/Templates.xaml | 15 ++++- .../ViewModels/ItemDetailsViewModel.cs | 46 +++++++++++++- src/JellyBox/ViewModels/LoginViewModel.cs | 24 +++++--- 5 files changed, 135 insertions(+), 14 deletions(-) diff --git a/src/JellyBox/Models/Card.cs b/src/JellyBox/Models/Card.cs index ad3d42c..0ab7790 100644 --- a/src/JellyBox/Models/Card.cs +++ b/src/JellyBox/Models/Card.cs @@ -16,6 +16,8 @@ internal sealed record Card : INavigable { public required string Name { get; init; } + public string? SecondaryText { get; init; } + public required int ImageWidth { get; init; } public required int ImageHeight { get; init; } @@ -41,4 +43,6 @@ internal sealed record Card : INavigable public string UnplayedCountText => UnplayedItemCount >= 100 ? "99+" : UnplayedItemCount.ToString(CultureInfo.InvariantCulture); public double ProgressWidth => ImageWidth * PlayedPercentage / 100.0; + + public bool HasSecondaryText => !string.IsNullOrEmpty(SecondaryText); } diff --git a/src/JellyBox/Models/CardFactory.cs b/src/JellyBox/Models/CardFactory.cs index b970b9f..9dab5dc 100644 --- a/src/JellyBox/Models/CardFactory.cs +++ b/src/JellyBox/Models/CardFactory.cs @@ -1,3 +1,4 @@ +using System.Globalization; using CommunityToolkit.Mvvm.Input; using JellyBox.Services; using Jellyfin.Sdk.Generated.Models; @@ -62,7 +63,8 @@ public Card CreateFromItem( return new Card { - Name = item.Name!, + Name = GetDisplayName(item), + SecondaryText = GetSecondaryText(item), ImageWidth = imageWidth, ImageHeight = imageHeight, Image = image, @@ -86,7 +88,7 @@ public Card CreateFromPerson( return new Card { Name = person.Name!, - // TODO: Cards need secondardy text to display Role + SecondaryText = person.Role, ImageWidth = imageWidth, ImageHeight = imageHeight, Image = image, @@ -94,6 +96,60 @@ public Card CreateFromPerson( }; } + private static string GetDisplayName(BaseItemDto item) + => item.Type switch + { + BaseItemDto_Type.Episode => item.SeriesName ?? item.Name!, + _ => item.Name!, + }; + + private static string? GetSecondaryText(BaseItemDto item) + => item.Type switch + { + BaseItemDto_Type.Episode => GetEpisodeText(item), + BaseItemDto_Type.Season => item.SeriesName, + BaseItemDto_Type.MusicAlbum => item.AlbumArtist, + BaseItemDto_Type.Audio => item.AlbumArtist ?? item.Album, + BaseItemDto_Type.MusicVideo => item.AlbumArtist ?? item.Album, + BaseItemDto_Type.Series => GetSeriesYearText(item), + BaseItemDto_Type.Movie => item.ProductionYear?.ToString(CultureInfo.InvariantCulture), + _ => null, + }; + + private static string? GetSeriesYearText(BaseItemDto item) + { + if (item.ProductionYear is null) + { + return null; + } + + string startYear = item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture); + + if (string.Equals(item.Status, "Continuing", StringComparison.OrdinalIgnoreCase)) + { + return $"{startYear} - Present"; + } + + if (item.EndDate.HasValue) + { + string endYear = item.EndDate.Value.Year.ToString(CultureInfo.InvariantCulture); + return endYear == startYear ? startYear : $"{startYear} - {endYear}"; + } + + return startYear; + } + + private static string GetEpisodeText(BaseItemDto item) + { + string prefix = item.ParentIndexNumber.HasValue && item.IndexNumber.HasValue + ? $"S{item.ParentIndexNumber.Value}:E{item.IndexNumber.Value} - " + : item.IndexNumber.HasValue + ? $"E{item.IndexNumber.Value} - " + : string.Empty; + + return $"{prefix}{item.Name}"; + } + private static double GetAspectRatio(CardShape shape) => shape switch { diff --git a/src/JellyBox/Resources/Templates.xaml b/src/JellyBox/Resources/Templates.xaml index 6331e70..0c5795a 100644 --- a/src/JellyBox/Resources/Templates.xaml +++ b/src/JellyBox/Resources/Templates.xaml @@ -52,6 +52,7 @@ + + diff --git a/src/JellyBox/ViewModels/ItemDetailsViewModel.cs b/src/JellyBox/ViewModels/ItemDetailsViewModel.cs index ba5677c..2c79b7f 100644 --- a/src/JellyBox/ViewModels/ItemDetailsViewModel.cs +++ b/src/JellyBox/ViewModels/ItemDetailsViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.Globalization; using System.Text; using System.Text.RegularExpressions; using CommunityToolkit.Mvvm.ComponentModel; @@ -270,9 +271,7 @@ private void DetermineVideoOptions(MediaSourceInfo mediaSourceInfo) string? displayTitle = videoStream.DisplayTitle; if (string.IsNullOrEmpty(displayTitle)) { - // DisplayTitle isn't always populated for video - // TODO: Get the resolution text and codec. See /src/controllers/itemDetails/index.js::renderVideoSelections - displayTitle = "TODO"; + displayTitle = GetVideoStreamDisplayTitle(videoStream); } int index = videoStream.Index.GetValueOrDefault(); @@ -708,6 +707,47 @@ private static Uri GetWebVideoUri(string url) @"(?https://www.youtube.com)/watch\?v=(?[^&]+)", RegexOptions.Compiled | RegexOptions.ExplicitCapture); + private static string GetVideoStreamDisplayTitle(MediaStream videoStream) + { + List parts = []; + + string? resolutionText = GetResolutionText(videoStream); + if (resolutionText is not null) + { + parts.Add(resolutionText); + } + + if (!string.IsNullOrEmpty(videoStream.Codec)) + { + parts.Add(videoStream.Codec.ToUpperInvariant()); + } + + return parts.Count > 0 ? string.Join(' ', parts) : string.Create(CultureInfo.InvariantCulture, $"{videoStream.Width}x{videoStream.Height}"); + } + + private static string? GetResolutionText(MediaStream stream) + { + int? width = stream.Width; + int? height = stream.Height; + + if (width is null || height is null) + { + return null; + } + + bool isInterlaced = stream.IsInterlaced ?? false; + + return (width, height) switch + { + ( >= 3800, _) or (_, >= 2000) => "4K", + ( >= 2500, _) or (_, >= 1400) => isInterlaced ? "1440i" : "1440p", + ( >= 1800, _) or (_, >= 1000) => isInterlaced ? "1080i" : "1080p", + ( >= 1200, _) or (_, >= 700) => isInterlaced ? "720i" : "720p", + ( >= 700, _) or (_, >= 400) => isInterlaced ? "480i" : "480p", + _ => null, + }; + } + private sealed class MediaStreamComparer : IComparer { private MediaStreamComparer() diff --git a/src/JellyBox/ViewModels/LoginViewModel.cs b/src/JellyBox/ViewModels/LoginViewModel.cs index 8fb50f7..f9f5e78 100644 --- a/src/JellyBox/ViewModels/LoginViewModel.cs +++ b/src/JellyBox/ViewModels/LoginViewModel.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using JellyBox.Services; @@ -68,7 +69,7 @@ public async void Initialize() } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Error in LoginViewModel.Initialize: {ex}"); + Debug.WriteLine($"Error in LoginViewModel.Initialize: {ex}"); } } @@ -105,8 +106,8 @@ private async Task SignInAsync(CancellationToken cancellationToken) } catch (Exception ex) { - // TODO: Need a friendlier message. - UpdateErrorMessage(ex.Message); + Debug.WriteLine($"Login failed: {ex}"); + UpdateErrorMessage(GetFriendlyErrorMessage(ex)); return; } finally @@ -177,7 +178,7 @@ await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync( { // Timer callbacks with async void can crash the app if exceptions propagate. // Log and suppress to prevent app termination. - System.Diagnostics.Debug.WriteLine($"Error in PollQuickConnectAsync: {ex}"); + Debug.WriteLine($"Error in PollQuickConnectAsync: {ex}"); } } @@ -185,8 +186,8 @@ await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync( } catch (Exception ex) { - // TODO: Need a friendlier message. - UpdateErrorMessage(ex.Message); + Debug.WriteLine($"Quick Connect failed: {ex}"); + UpdateErrorMessage(GetFriendlyErrorMessage(ex)); return; } finally @@ -207,8 +208,7 @@ private bool HandleAuthenticationResult(AuthenticationResult? authenticationResu string? accessToken = authenticationResult?.AccessToken; if (accessToken is null) { - // TODO - UpdateErrorMessage("Unexpected authentication failure"); + UpdateErrorMessage("Authentication failed. Please try again."); return false; } @@ -232,4 +232,12 @@ private void UpdateErrorMessage(string message) ShowErrorMessage = true; ErrorMessage = message; } + + private static string GetFriendlyErrorMessage(Exception ex) + => ex switch + { + HttpRequestException => "Unable to connect to the server. Please check your connection and try again.", + TaskCanceledException => "The request timed out. Please try again.", + _ => "An error occurred during login. Please try again.", + }; }