App preview added

This commit is contained in:
PatrickSt1991
2026-01-29 19:34:02 +01:00
parent a1cac4ae17
commit c4a4e947ba
6 changed files with 355 additions and 112 deletions

View File

@@ -82,7 +82,7 @@ namespace Jellyfin2Samsung.Helpers
// ----- Application-scoped settings (readonly at runtime) -----
public string ReleasesUrl { get; set; } = "https://api.github.com/repos/jeppevinkel/jellyfin-tizen-builds/releases";
public string AuthorEndpoint { get; set; } = "https://dev.tizen.samsung.com/apis/v2/authors";
public string AppVersion { get; set; } = "v2.2.0.0";
public string AppVersion { get; set; } = "v2.2.0.1";
public string TizenSdb { get; set; } = "https://api.github.com/repos/PatrickSt1991/tizen-sdb/releases";
public string JellyfinAvRelease { get; set; } = "https://api.github.com/repos/PatrickSt1991/tizen-jellyfin-avplay/releases";
public string JellyfinAvReleaseFork { get; set; } = "https://api.github.com/repos/asamahy/tizen-jellyfin-avplay/releases";

View File

@@ -17,6 +17,20 @@ namespace Jellyfin2Samsung.Helpers.Core
public const string CustomWgtFile = "Custom WGT File";
}
/// <summary>
/// preview image URLS for different apps.
/// </summary>
public static class PreviewImages
{
public const string Jellyfin = "https://jellyfin.org/assets/images/10.8-home-4a73a92bf90d1eeffa5081201ca9c7bb.png";
public const string Moonfin = "https://iili.io/fs8W4Re.png";
public const string Moonlight = "https://iili.io/fsvn6mJ.png";
public const string Fireplace = "https://raw.githubusercontent.com/thonythony/fireplace/refs/heads/master/icon.jpg";
public const string TVApp = "https://iili.io/fsvaHsn.png";
public const string Twitch = "https://iili.io/fsvUNu2.md.gif";
public const string ClubInfoBoard = "https://iili.io/fsviHQV.png";
}
/// <summary>
/// Tizen installation error codes returned by the SDB tool.
/// </summary>

View File

@@ -6,5 +6,6 @@ namespace Jellyfin2Samsung.Models
{
[ObservableProperty] private string fileName = string.Empty;
[ObservableProperty] private string description = string.Empty;
[ObservableProperty] private string repoUrl = string.Empty;
}
}

View File

@@ -0,0 +1,9 @@
using Avalonia.Media;
namespace Jellyfin2Samsung.Models;
public sealed class ProviderOption
{
public string DisplayName { get; init; } = "";
public IImage? PreviewImage { get; init; } // can be a Bitmap later
}

View File

@@ -1,12 +1,18 @@
using CommunityToolkit.Mvvm.Input;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Jellyfin2Samsung.Helpers;
using Jellyfin2Samsung.Helpers.Core;
using Jellyfin2Samsung.Models;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin2Samsung.ViewModels
@@ -16,21 +22,53 @@ namespace Jellyfin2Samsung.ViewModels
public ObservableCollection<BuildVersion> JellyfinVersions { get; } = new();
public ObservableCollection<BuildVersion> CommunityApps { get; } = new();
public ObservableCollection<ProviderOption> ProviderOptions { get; } = new();
[ObservableProperty]
private ProviderOption? selectedProviderOption;
private static readonly HttpClient _http = new();
private readonly Dictionary<string, Bitmap?> _bitmapCache = new(StringComparer.OrdinalIgnoreCase);
private bool _isLoading;
private int _rebuildVersion;
public BuildInfoViewModel()
{
CommunityApps.CollectionChanged += (_, __) =>
{
if (_isLoading) return;
QueueRebuild();
};
JellyfinVersions.CollectionChanged += (_, __) =>
{
if (_isLoading) return;
QueueRebuild();
};
_ = LoadAsync();
}
private void QueueRebuild()
{
// Start a rebuild, but only the latest one is allowed to apply results
var version = Interlocked.Increment(ref _rebuildVersion);
_ = RebuildProviderOptionsAsync(version);
}
public async Task LoadAsync()
{
try
{
using var client = new HttpClient();
_isLoading = true;
var jellyfinMd = await client.GetStringAsync(AppSettings.Default.ReleaseInfo);
var communityMd = await client.GetStringAsync(AppSettings.Default.CommunityInfo);
var jellyfinMd = await _http.GetStringAsync(AppSettings.Default.ReleaseInfo);
var communityMd = await _http.GetStringAsync(AppSettings.Default.CommunityInfo);
JellyfinVersions.Clear();
CommunityApps.Clear();
// Parse Jellyfin table
ParseVersionsTable(jellyfinMd, JellyfinVersions);
JellyfinVersions.Add(new BuildVersion
@@ -39,7 +77,6 @@ namespace Jellyfin2Samsung.ViewModels
Description = "Moonfin is optimized for the viewing experience on Samsung Smart TVs."
});
// Static Jellyfin entries
JellyfinVersions.Add(new BuildVersion
{
FileName = "Legacy",
@@ -55,19 +92,141 @@ namespace Jellyfin2Samsung.ViewModels
JellyfinVersions.Add(new BuildVersion
{
FileName = "AVPlay 10.10.z - SmartHub",
Description = "Includes AVPlay video player patches for better Samsung TV compatibility for10.10.z SmartHub variant"
Description = "Includes AVPlay video player patches for better Samsung TV compatibility for 10.10.z SmartHub variant"
});
// Parse community apps
ParseApplicationsTable(communityMd, CommunityApps);
}
catch (Exception ex)
{
Trace.WriteLine($"Failed to load build info: {ex}");
}
finally
{
_isLoading = false;
}
// Do a single authoritative rebuild after load finishes
var version = Interlocked.Increment(ref _rebuildVersion);
await RebuildProviderOptionsAsync(version);
if (SelectedProviderOption is null && ProviderOptions.Count > 0)
SelectedProviderOption = ProviderOptions[0];
}
// Remove markdown formatting like **bold**, emoji, etc.
private async Task RebuildProviderOptionsAsync(int version)
{
// If a newer rebuild was queued, abandon this one
if (version != Volatile.Read(ref _rebuildVersion))
return;
// Map ONLY the apps that truly need unique thumbnails
var communityPreviewUrls = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "Moonlight", Constants.PreviewImages.Moonlight },
{ "Fireplace", Constants.PreviewImages.Fireplace },
{ "TVApp", Constants.PreviewImages.TVApp },
{ "Twitch", Constants.PreviewImages.Twitch },
{ "Club Info Board", Constants.PreviewImages.ClubInfoBoard }
};
// Exceptions inside JellyfinVersions that should NOT use Jellyfin image
var jellyfinOverrides = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "Moonfin", Constants.PreviewImages.Moonfin }
};
var jellyfinBitmap = await LoadBitmapAsync(Constants.PreviewImages.Jellyfin);
// Build locally (dont mutate ObservableCollection from background thread)
var built = new List<ProviderOption>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void AddIfNew(string name, Bitmap? bmp)
{
name = (name ?? string.Empty).Trim();
if (name.Length == 0) return;
if (!seen.Add(name)) return;
built.Add(new ProviderOption
{
DisplayName = name,
PreviewImage = bmp
});
}
// 1) Jellyfin top entry
AddIfNew("Jellyfin", jellyfinBitmap);
// 2) Community apps
foreach (var app in CommunityApps)
{
var name = (app.FileName ?? string.Empty).Trim();
if (name.Length == 0) continue;
var url = communityPreviewUrls.FirstOrDefault(kvp =>
name.Contains(kvp.Key, StringComparison.OrdinalIgnoreCase)).Value;
var bmp = url is not null ? await LoadBitmapAsync(url) : null;
AddIfNew(name, bmp);
}
// 3) Jellyfin builds (default Jellyfin image, override forks like Moonfin)
foreach (var build in JellyfinVersions)
{
var name = (build.FileName ?? string.Empty).Trim();
if (name.Length == 0) continue;
Bitmap? bmp = jellyfinBitmap;
var overrideUrl = jellyfinOverrides.FirstOrDefault(kvp =>
name.Contains(kvp.Key, StringComparison.OrdinalIgnoreCase)).Value;
if (overrideUrl is not null)
bmp = await LoadBitmapAsync(overrideUrl);
AddIfNew(name, bmp);
}
// If a newer rebuild was queued while we were downloading images, abandon this one
if (version != Volatile.Read(ref _rebuildVersion))
return;
await Dispatcher.UIThread.InvokeAsync(() =>
{
ProviderOptions.Clear();
foreach (var opt in built)
ProviderOptions.Add(opt);
if (SelectedProviderOption is null && ProviderOptions.Count > 0)
SelectedProviderOption = ProviderOptions[0];
});
}
private async Task<Bitmap?> LoadBitmapAsync(string url)
{
if (_bitmapCache.TryGetValue(url, out var cached))
return cached;
try
{
var bytes = await _http.GetByteArrayAsync(url);
await using var ms = new MemoryStream(bytes);
var bmp = new Bitmap(ms);
_bitmapCache[url] = bmp;
return bmp;
}
catch (Exception ex)
{
Trace.WriteLine($"Failed to load image '{url}': {ex.Message}");
_bitmapCache[url] = null;
return null;
}
}
// -------- your existing parsing methods (unchanged) --------
private static string CleanText(string input)
{
var text = RegexPatterns.BuildInfo.MarkdownBold.Replace(input, "$1");
@@ -78,22 +237,19 @@ namespace Jellyfin2Samsung.ViewModels
private void ParseVersionsTable(string md, ObservableCollection<BuildVersion> target)
{
var match = RegexPatterns.BuildInfo.VersionsTable.Match(md);
if (!match.Success) return;
var table = match.Groups["table"].Value;
var rows = RegexPatterns.BuildInfo.TableRow2Columns.Matches(table);
bool headerSkipped = false;
foreach (Match row in rows)
foreach (System.Text.RegularExpressions.Match row in rows)
{
var col1 = row.Groups[1].Value.Trim();
var col2 = row.Groups[2].Value.Trim();
if (!headerSkipped &&
col1.Equals("File name", StringComparison.OrdinalIgnoreCase))
if (!headerSkipped && col1.Equals("File name", StringComparison.OrdinalIgnoreCase))
{
headerSkipped = true;
continue;
@@ -113,22 +269,22 @@ namespace Jellyfin2Samsung.ViewModels
private void ParseApplicationsTable(string md, ObservableCollection<BuildVersion> target)
{
var match = RegexPatterns.BuildInfo.ApplicationsTable.Match(md);
if (!match.Success) return;
var table = match.Groups["table"].Value;
var rows = RegexPatterns.BuildInfo.TableRow3Columns.Matches(table);
bool headerSkipped = false;
foreach (Match row in rows)
foreach (System.Text.RegularExpressions.Match row in rows)
{
var col1 = row.Groups[1].Value.Trim();
var col2 = row.Groups[2].Value.Trim();
var col3 = row.Groups[3].Value.Trim();
if (!headerSkipped &&
col1.Contains("Application", StringComparison.OrdinalIgnoreCase))
Debug.WriteLine(col3);
if (!headerSkipped && col1.Contains("Application", StringComparison.OrdinalIgnoreCase))
{
headerSkipped = true;
continue;
@@ -153,4 +309,4 @@ namespace Jellyfin2Samsung.ViewModels
public event Action? OnRequestClose;
}
}
}

View File

@@ -4,9 +4,9 @@
xmlns:viewModels="clr-namespace:Jellyfin2Samsung.ViewModels"
x:DataType="viewModels:BuildInfoViewModel"
SizeToContent="WidthAndHeight"
MinWidth="650"
MaxWidth="900"
MinHeight="500"
MinWidth="900"
MaxWidth="1200"
MinHeight="520"
WindowStartupLocation="CenterOwner"
Title="Jellyfin Tizen Versions">
@@ -15,112 +15,175 @@
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Window.Styles>
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="16"
CornerRadius="8"
Margin="12"
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
BorderThickness="1">
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
Padding="16"
CornerRadius="8"
Margin="12"
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
BorderThickness="1">
<Border.Effect>
<DropShadowEffect BlurRadius="15"
OffsetX="2"
OffsetY="2"
Color="#00000020"
Opacity="0.15"/>
OffsetX="2"
OffsetY="2"
Color="#00000020"
Opacity="0.15"/>
</Border.Effect>
<StackPanel Spacing="20">
<StackPanel Spacing="16">
<!-- Header -->
<Border Background="#2C3E50"
Padding="14,10"
CornerRadius="6">
Padding="14,10"
CornerRadius="6">
<TextBlock Text="Jellyfin Tizen Build Versions"
Foreground="White"
FontWeight="SemiBold"
FontSize="18"
HorizontalAlignment="Center"/>
Foreground="White"
FontWeight="SemiBold"
FontSize="18"
HorizontalAlignment="Center"/>
</Border>
<!-- Two-column layout -->
<Grid ColumnDefinitions="*,340"
ColumnSpacing="18">
<Border Background="#34495E"
Padding="10"
CornerRadius="6">
<TextBlock Text="Official Jellyfin Builds"
Foreground="White"
FontWeight="Medium"
FontSize="15"/>
</Border>
<!-- ================= LEFT COLUMN ================= -->
<StackPanel Grid.Column="0" Spacing="16">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1"
CornerRadius="4"
Height="200">
<DataGrid ItemsSource="{Binding JellyfinVersions}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
GridLinesVisibility="All"
IsReadOnly="True"
ColumnWidth="*">
<DataGrid.Columns>
<DataGridTextColumn Header="File name" Width="180"
Binding="{Binding FileName}" />
<DataGridTemplateColumn Header="Description" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Description}"
TextWrapping="Wrap"
Padding="4"
VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Border>
<Border Background="#34495E"
Padding="10"
CornerRadius="6">
<TextBlock Text="Jellyfin Builds"
Foreground="White"
FontWeight="Medium"
FontSize="15"/>
</Border>
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1"
CornerRadius="4"
Height="220">
<DataGrid ItemsSource="{Binding JellyfinVersions}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
GridLinesVisibility="All"
IsReadOnly="True"
ColumnWidth="*">
<DataGrid.Columns>
<DataGridTextColumn Header="File name" Width="180"
Binding="{Binding FileName}" />
<DataGridTemplateColumn Header="Description" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Description}"
TextWrapping="Wrap"
Padding="4"
VerticalAlignment="Center"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Border>
<Border Background="#34495E"
Padding="10"
CornerRadius="6"
Margin="0,6,0,0">
<TextBlock Text="Community Applications"
Foreground="White"
FontWeight="Medium"
FontSize="15"/>
</Border>
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1"
CornerRadius="4"
Height="280">
<DataGrid ItemsSource="{Binding CommunityApps}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
GridLinesVisibility="All"
RowHeight="32"
IsReadOnly="True"
ColumnWidth="*">
<DataGrid.Columns>
<DataGridTextColumn Header="Application" Width="180"
Binding="{Binding FileName}" />
<DataGridTextColumn Header="Description" Width="*"
Binding="{Binding Description}" />
</DataGrid.Columns>
</DataGrid>
</Border>
</StackPanel>
<!-- ================= RIGHT COLUMN ================= -->
<!-- CompileBindings off so you can iterate on option types freely -->
<StackPanel Grid.Column="1"
Spacing="10"
x:CompileBindings="False">
<Border Background="#34495E"
Padding="10"
CornerRadius="6">
<TextBlock Text="App Preview"
Foreground="White"
FontWeight="Medium"
FontSize="15"/>
</Border>
<!-- Dropdown: TEXT ONLY (no preview image here) -->
<ComboBox ItemsSource="{Binding ProviderOptions}"
SelectedItem="{Binding SelectedProviderOption, Mode=TwoWay}"
MinWidth="320"
HorizontalAlignment="Stretch"
DisplayMemberBinding="{Binding DisplayName}"/>
<!-- Preview card: image shows ONLY here -->
<Border Height="320"
CornerRadius="12"
ClipToBounds="True"
Background="#22000000">
<Grid>
<!-- fallback text behind -->
<StackPanel VerticalAlignment="Center"
HorizontalAlignment="Center"
Spacing="6"
Margin="12">
<TextBlock Text="No preview available"
FontWeight="SemiBold"
FontSize="14"
HorizontalAlignment="Center"/>
<TextBlock Text="This app has no thumbnail."
Opacity="0.7"
FontSize="12"
HorizontalAlignment="Center"/>
</StackPanel>
<!-- image on top (if null, it simply won't render) -->
<Image Source="{Binding SelectedProviderOption.PreviewImage}"
Stretch="Uniform"
StretchDirection="DownOnly"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
</Border>
<Border Background="#34495E"
Padding="10"
CornerRadius="6"
Margin="0,10,0,0">
<TextBlock Text="Community Applications"
Foreground="White"
FontWeight="Medium"
FontSize="15"/>
</Border>
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"
BorderThickness="1"
CornerRadius="4"
Height="260">
<DataGrid ItemsSource="{Binding CommunityApps}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
GridLinesVisibility="All"
RowHeight="32"
IsReadOnly="True"
ColumnWidth="*">
<DataGrid.Columns>
<DataGridTextColumn Header="Application" Width="180"
Binding="{Binding FileName}" />
<DataGridTextColumn Header="Description" Width="*"
Binding="{Binding Description}" />
</DataGrid.Columns>
</DataGrid>
</Border>
</StackPanel>
</Grid>
<!-- Footer -->
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,10,0,0">
HorizontalAlignment="Right"
Margin="0,6,0,0">
<Button Content="Close"
Width="100"
Command="{Binding CloseCommand}"/>
Width="100"
Command="{Binding CloseCommand}"/>
</StackPanel>
</StackPanel>
</Border>
</Window>
</Window>