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.",
+ };
}